Code-review 2026-05-20 sweep: re-review at 1cd51bb, resolve 72 findings across all 11 modules
Re-reviewed every module/client against the 10-category checklist
(REVIEW-PROCESS.md) at commit 1cd51bb, filed 72 new findings, and
fixed them in three priority waves (3 High, 17 Medium, 52 Low).
Highs
- Server-017: enumerate AcknowledgeAlarm / QueryActiveAlarms in
GatewayGrpcScopeResolver so non-admin keys can use them; document
the mapping in docs/Authorization.md; add interceptor tests.
- Client.Java-013: add the five missing bulk-method stubs to the
CLI FakeSession so the test module compiles on a clean tree.
- Client.Rust-013: fix the clippy::doc_lazy_continuation regression
in generated tonic code by reformatting the ReadBulkCommand proto
comment and scoping a #![allow(...)] to the generated submodules.
Mediums (highlights)
- Server: unify GatewaySession state-lock discipline (-015) and
make DisposeAsync race-safe against in-flight CloseAsync (-016);
add constraint-enforcement test coverage for the bulk-plan path
(-021).
- Worker: introduce StaRuntimeShutdownException so RunAlarmPollLoop
can distinguish graceful shutdown from a real STA-affinity
violation (-016); have the watchdog skip StaHung while
CurrentCommandCorrelationId is non-empty so a legitimate slow
ReadBulk no longer self-faults (-017).
- Tests: add per-method round-trip + cancellation coverage for the
11 GatewaySession bulk methods (-013); replace the real TCP probe
in GalaxyHierarchyCacheTests with an IGalaxyRepository fake
(-016).
- IntegrationTests: drive the StreamEvents writer in the live Write
test and assert OnWriteComplete (-012); add live tests for
Unadvise/RemoveItem/Unregister ordering, WriteSecured, and
abnormal worker exit (-014).
- Worker.Tests: replace MxAccessSession reflection with an internal
CreateForTesting factory (-016); cover WorkerCancel and
unexpected-body envelope branches (-017).
- Client.Java: cancel MxEventStream when close() races
beforeStart() (-014); return a CancellingCompletableFuture that
actually forwards cancellation through .thenApply chains (-015).
- Client.Python: drop the silent localhost-plaintext downgrade in
the CLI; require explicit --plaintext (-013).
- Client.Rust: stop bench-read-bulk from polluting success-latency
histograms with failed-call durations (-015); add coverage for
the five MalformedReply paths, the bulk-write helpers, the
Error::Unavailable mapping, and the unary-fault path (-016).
- Contracts: extend docs/Contracts.md with the bulk read/write
command family (-009).
Lows (highlights)
- Server: cap GalaxyGlobMatcher.RegexCache; align
WorkerAlarmRpcDispatcher missing-session handling; drop the
duplicate dashboard @page routes; refresh IAlarmRpcDispatcher
XML doc.
- Worker: surface SetXmlAlarmQuery COM failures; remove dead
subscriptionExpression / ExecutingCommand arms; preserve
factory-supplied runtime sessions; split MxAlarmSnapshot.cs into
three files.
- Tests: dispose the WebApplication in seven test classes; rebuild
FakeWorkerProcess.WaitForExitAsync against a real TaskCompletion
source; switch the heartbeat-expires test to ManualTimeProvider;
add InvariantCulture to the remaining DateTimeOffset.Parse sites;
document GalaxyFilterInputSafetyTests in GatewayTesting.md.
- IntegrationTests: comment fixes, RecordingServerStreamWriter
IDisposable, class-level [Trait], single-source ZB default
connection string.
- Worker.Tests: replace silent-return gating with LiveMxAccessFact
so absent env vars SKIP not pass; PascalCase rename of probe
[Fact]s; deterministic deadline test; new frame-protocol error
tests; ComputeTransitions diff-coverage; relocate dev-rig probes
to Probes/.
- Contracts: add round-trip coverage and per-field redaction /
Galaxy-identifier comments to the protos.
- Client.Dotnet: introduce clients/dotnet/Directory.Build.props so
TreatWarningsAsErrors / analysers apply; document
DiscoverHierarchyOptions and IMxGatewayCliClient; require typed
bulk-read handles in CLI; surface AcknowledgeAlarm transport
faults through Translate().
- Client.Go: kill dead code in alarms_test / fakeGalaxyServer /
runWriteBulkVariant; document the six new subcommands in
writeUsage; drain galaxy-watch events on limit; switch io.EOF
comparisons to errors.Is.
- Client.Java: shared shutdown helpers + new shutdownTimeout
option; regex-based credential redaction; Long.toUnsignedString
for uint64 sequence; doc fixes.
- Client.Python: combine duplicate imports; add coverage for
_percentile / bench-read-bulk / MAX_AGGREGATE_EVENTS /
_api_key_from_env; populate pyproject metadata and ship py.typed.
- Client.Rust: expose next_correlation_id() so CLI ping/close
stop hard-coding correlation IDs; resync RustClientDesign.md
with the current Session / Error surface and CLI subcommand set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,779 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using AlarmMgrDataProviderCOM;
|
||||
using aaAlarmManagedClient;
|
||||
using ArchestrA.MxAccess;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace MxGateway.Worker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime probe — registers as an AlarmClient consumer with a real
|
||||
/// hidden message-only window, subscribes to a Galaxy alarm provider,
|
||||
/// and logs every Win32 message that arrives during a fixed pump
|
||||
/// window. The intent is to identify the WM_APP / RegisterWindowMessage
|
||||
/// ID that AVEVA's alarm provider posts when alarms change, plus the
|
||||
/// <c>wParam</c>/<c>lParam</c> semantics on each.
|
||||
///
|
||||
/// Skip-gated by default; flip Skip=null and run against the live dev
|
||||
/// rig to capture output. Requires:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A reachable Galaxy with at least one alarmable object.</description></item>
|
||||
/// <item><description>The configured Galaxy expression below to match a real provider (default <c>"\\Galaxy"</c> — adjust if needed).</description></item>
|
||||
/// <item><description>An alarm trigger during the pump window (raise / ack / clear something in the Galaxy via System Platform IDE) — without one, only ambient activity is captured.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class AlarmClientWmProbeTests : IDisposable
|
||||
{
|
||||
// Probe configuration. Override in the constructor below if needed.
|
||||
// Try multiple subscription expressions sequentially (each Subscribe call
|
||||
// adds to the consumer's scope). The "everything" form varies by AVEVA
|
||||
// version — we shotgun common forms.
|
||||
// Canonical AlarmClient subscription format (per ArchestrA docs):
|
||||
// \\Node\Provider!Area!Filter
|
||||
// - Node: machine name (NOT galaxy name; "Galaxy" is the literal provider)
|
||||
// - Provider: literal "Galaxy"
|
||||
// - Area: area object the engine hosts the alarm under
|
||||
// Note: each Subscribe call REPLACES the prior subscription on the
|
||||
// consumer, so we test exactly one expression per probe run.
|
||||
private static readonly string MachineName = Environment.MachineName;
|
||||
private static readonly string[] SubscriptionExpressions =
|
||||
{
|
||||
// DEV is the top-level area on the Platform (TestArea is contained
|
||||
// within DEV). Alarms typically publish at the platform's primary
|
||||
// area. If TestArea-only doesn't catch them, DEV should.
|
||||
$@"\\{MachineName}\Galaxy!DEV",
|
||||
};
|
||||
private const string SubscriptionExpression = @"\Galaxy!";
|
||||
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
|
||||
private static readonly TimeSpan FireMarkerAt = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan ClearMarkerAt = TimeSpan.FromSeconds(35);
|
||||
// Tag the operator should flip while the probe is pumping. Default
|
||||
// matches the dev rig's known alarmable boolean.
|
||||
private const string TriggerTagReference = "TestMachine_001.TestAlarm001";
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CreateWindowExW")]
|
||||
private static extern IntPtr CreateWindowEx(
|
||||
int dwExStyle, string lpClassName, string lpWindowName,
|
||||
int dwStyle, int X, int Y, int nWidth, int nHeight,
|
||||
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern ushort RegisterClassW(ref WNDCLASSW lpWndClass);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool UnregisterClassW(string lpClassName, IntPtr hInstance);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DefWindowProcW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool TranslateMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr GetModuleHandle(string lpModuleName);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern uint RegisterWindowMessage(string lpString);
|
||||
|
||||
private const int HWND_MESSAGE = -3;
|
||||
private const uint PM_REMOVE = 0x0001;
|
||||
|
||||
private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct WNDCLASSW
|
||||
{
|
||||
public uint style;
|
||||
public IntPtr lpfnWndProc;
|
||||
public int cbClsExtra;
|
||||
public int cbWndExtra;
|
||||
public IntPtr hInstance;
|
||||
public IntPtr hIcon;
|
||||
public IntPtr hCursor;
|
||||
public IntPtr hbrBackground;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string? lpszMenuName;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MSG
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public int x;
|
||||
public int y;
|
||||
}
|
||||
|
||||
private readonly ITestOutputHelper output;
|
||||
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
|
||||
private readonly Stopwatch elapsed = Stopwatch.StartNew();
|
||||
private GCHandle wndProcHandle;
|
||||
private IntPtr probeWindow = IntPtr.Zero;
|
||||
private string? registeredClass;
|
||||
|
||||
public AlarmClientWmProbeTests(ITestOutputHelper output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture alarm-path behavior")]
|
||||
public void ProbeAlarmClient_OnDevRig_LogsAlarmWindowMessages()
|
||||
{
|
||||
// 1. Pre-resolve a few candidate RegisterWindowMessage strings so any
|
||||
// matches in the captured log can be labeled. None of these is
|
||||
// confirmed; we record what each resolves to so the actual AVEVA
|
||||
// message ID (whatever it turns out to be) can be cross-referenced.
|
||||
string[] candidateNames =
|
||||
{
|
||||
"WW_AlarmConsumer", "WW_AlarmManager", "WW_Alarm",
|
||||
"WNAL_AlarmChange", "WNAL_AlarmChanges", "WNAL_AlarmNotify",
|
||||
"WNAL_Notify", "WNAL_ChangeNotification",
|
||||
"AlarmManager.Notify", "AlarmManagerNotify",
|
||||
"ArchestrA.AlarmChange", "AVEVA.AlarmNotify",
|
||||
"aaAlarmManagedClient.Notify",
|
||||
"GotAlarmChanges", "OnAlarmChanges",
|
||||
};
|
||||
foreach (string name in candidateNames)
|
||||
{
|
||||
uint id = RegisterWindowMessage(name);
|
||||
output.WriteLine($"RegisterWindowMessage(\"{name}\") -> 0x{id:X4} ({id})");
|
||||
}
|
||||
output.WriteLine("");
|
||||
|
||||
// 2. Spin up a single STA-affinitized thread, create a hidden message-
|
||||
// only window owned by it, run RegisterConsumer + Subscribe against
|
||||
// that window's hWnd, then pump messages on that thread for the
|
||||
// configured duration. Threading discipline matches the worker's
|
||||
// StaRuntime model.
|
||||
Exception? threadException = null;
|
||||
var pumpDone = new ManualResetEventSlim(false);
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
RunProbe();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
threadException = ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
pumpDone.Set();
|
||||
}
|
||||
});
|
||||
thread.IsBackground = false;
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
pumpDone.Wait();
|
||||
thread.Join();
|
||||
|
||||
// 3. Drain the log to xunit output regardless of outcome — partial
|
||||
// captures are still informative.
|
||||
output.WriteLine("");
|
||||
output.WriteLine($"Captured {log.Count} log line(s):");
|
||||
while (log.TryDequeue(out string? line))
|
||||
{
|
||||
output.WriteLine(line);
|
||||
}
|
||||
|
||||
if (threadException != null)
|
||||
{
|
||||
throw threadException;
|
||||
}
|
||||
}
|
||||
|
||||
private void RunProbe()
|
||||
{
|
||||
// 3a. Register a window class and create a message-only window.
|
||||
WndProc wndProc = ProbeWndProc;
|
||||
wndProcHandle = GCHandle.Alloc(wndProc); // keep delegate alive
|
||||
|
||||
registeredClass = "MxGatewayAlarmProbe_" + Guid.NewGuid().ToString("N");
|
||||
var cls = new WNDCLASSW
|
||||
{
|
||||
style = 0,
|
||||
lpfnWndProc = Marshal.GetFunctionPointerForDelegate(wndProc),
|
||||
hInstance = GetModuleHandle(null!),
|
||||
lpszClassName = registeredClass,
|
||||
};
|
||||
ushort atom = RegisterClassW(ref cls);
|
||||
if (atom == 0)
|
||||
{
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
Log($"RegisterClass failed err=0x{err:X8}");
|
||||
return;
|
||||
}
|
||||
Log($"RegisterClass ok atom=0x{atom:X4} class={registeredClass}");
|
||||
|
||||
probeWindow = CreateWindowEx(
|
||||
dwExStyle: 0, lpClassName: registeredClass, lpWindowName: "AlarmProbe",
|
||||
dwStyle: 0, X: 0, Y: 0, nWidth: 0, nHeight: 0,
|
||||
hWndParent: (IntPtr)HWND_MESSAGE, hMenu: IntPtr.Zero,
|
||||
hInstance: cls.hInstance, lpParam: IntPtr.Zero);
|
||||
if (probeWindow == IntPtr.Zero)
|
||||
{
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
Log($"CreateWindowEx(HWND_MESSAGE) failed err=0x{err:X8}");
|
||||
return;
|
||||
}
|
||||
Log($"Created message-only window hWnd=0x{probeWindow.ToInt64():X}");
|
||||
|
||||
// 3b. Create the AlarmClient and try the lifecycle. RegisterConsumer
|
||||
// accepts an int hWnd — narrow the IntPtr (sufficient on x86).
|
||||
AlarmClient? client = null;
|
||||
try
|
||||
{
|
||||
client = new AlarmClient();
|
||||
|
||||
// One-time interop introspection: dump AlarmClient's class GUID
|
||||
// (CoClass IID) and every interface it implements with their
|
||||
// GUID + InterfaceType. The IID we need to redeclare with safe
|
||||
// blittable types is the one whose vtable carries
|
||||
// GetHighPriAlarm.
|
||||
try
|
||||
{
|
||||
Type ct = client.GetType();
|
||||
Log($"=== AlarmClient interop introspection ===");
|
||||
Log($"Class FullName: {ct.FullName}");
|
||||
var classGuid = ct.GetCustomAttributes(typeof(System.Runtime.InteropServices.GuidAttribute), true)
|
||||
.Cast<System.Runtime.InteropServices.GuidAttribute>().FirstOrDefault();
|
||||
Log($"Class GUID: {classGuid?.Value ?? "(none)"}");
|
||||
foreach (var iface in ct.GetInterfaces())
|
||||
{
|
||||
var ig = iface.GetCustomAttributes(typeof(System.Runtime.InteropServices.GuidAttribute), true)
|
||||
.Cast<System.Runtime.InteropServices.GuidAttribute>().FirstOrDefault();
|
||||
var ity = iface.GetCustomAttributes(typeof(System.Runtime.InteropServices.InterfaceTypeAttribute), true)
|
||||
.Cast<System.Runtime.InteropServices.InterfaceTypeAttribute>().FirstOrDefault();
|
||||
int methodCount = iface.GetMethods().Length;
|
||||
Log($" iface {iface.FullName} | GUID={ig?.Value ?? "(none)"} | type={ity?.Value.ToString() ?? "(none)"} | methods={methodCount}");
|
||||
}
|
||||
// Dump fields (private/internal) — the COM object reference
|
||||
// is likely on a private field.
|
||||
Log($"--- AlarmClient instance fields ---");
|
||||
foreach (var f in ct.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
Log($" field {f.FieldType.FullName} {f.Name} (public={f.IsPublic})");
|
||||
}
|
||||
// Dump base class chain.
|
||||
Log($"--- base class chain ---");
|
||||
Type? baseT = ct.BaseType;
|
||||
int depth = 0;
|
||||
while (baseT != null && depth < 5)
|
||||
{
|
||||
Log($" base[{depth}]: {baseT.FullName}");
|
||||
baseT = baseT.BaseType;
|
||||
depth++;
|
||||
}
|
||||
Log($"=== end introspection ===");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Interop introspection threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Try InitializeConsumer first — separate from RegisterConsumer
|
||||
// per the discovered API surface; previous probe runs skipped
|
||||
// it. Some AVEVA managed-client patterns require Initialize
|
||||
// before Register; others reverse the order. Try Initialize
|
||||
// first; on failure proceed to Register.
|
||||
try
|
||||
{
|
||||
int init = client.InitializeConsumer("AlarmProbe.Tests");
|
||||
Log($"InitializeConsumer -> {init}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"InitializeConsumer threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
int register = client.RegisterConsumer(
|
||||
hWnd: probeWindow.ToInt32(),
|
||||
szProductName: "AlarmProbe",
|
||||
szApplicationName: "AlarmProbe.Tests",
|
||||
szVersion: "1.0",
|
||||
bRetainHiddenAlarms: false);
|
||||
Log($"RegisterConsumer -> {register}");
|
||||
|
||||
LogProviders(client, "after Register");
|
||||
|
||||
// Dump the eQueryType enum so we can see what alternatives exist
|
||||
// beyond qtSummary, in case Summary aggregates and we need a
|
||||
// List/Snapshot mode instead.
|
||||
try
|
||||
{
|
||||
Type qt = typeof(eQueryType);
|
||||
Log($"eQueryType enum values: " +
|
||||
string.Join(", ", Enum.GetNames(qt).Select(n =>
|
||||
$"{n}=0x{Convert.ToInt32(Enum.Parse(qt, n)):X}")));
|
||||
Type af = typeof(eAlarmFilterState);
|
||||
Log($"eAlarmFilterState enum values: " +
|
||||
string.Join(", ", Enum.GetNames(af).Select(n =>
|
||||
$"{n}=0x{Convert.ToInt32(Enum.Parse(af, n)):X}")));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Enum dump threw: {ex.Message}");
|
||||
}
|
||||
|
||||
// qtHistory + state=ActiveNow: stream historical alarm transitions
|
||||
// including active alarms. asNone for FilterMask/Spec might
|
||||
// literally mean "match alarms in state 'none'" (i.e., nothing),
|
||||
// since the eAlarmFilterState enum is 0/1/2/3 single-states not
|
||||
// flag bits. Try ActiveNow explicitly.
|
||||
// Subscribe to every candidate expression — AVEVA accepts multiple
|
||||
// overlapping subscriptions; whichever matches the producer wins.
|
||||
foreach (string expr in SubscriptionExpressions)
|
||||
{
|
||||
try
|
||||
{
|
||||
int subscribe = client.Subscribe(
|
||||
szSubscription: expr,
|
||||
wFromPri: 0, wToPri: short.MaxValue,
|
||||
QueryType: eQueryType.qtSummary,
|
||||
SortFlags: eSortFlags.sfReturnNewestFirst,
|
||||
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
||||
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
||||
Log($"Subscribe('{expr}') -> {subscribe}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Subscribe('{expr}') threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
LogProviders(client, "after Subscribe-multi");
|
||||
|
||||
// 3c. Pump for the configured duration. Log every message we see
|
||||
// (filtered light to avoid noise from WM_PAINT / WM_TIMER /
|
||||
// WM_GETICON spam from typical pumps). Poll GetStatistics on
|
||||
// a tight cadence so any alarm transition is captured. Print
|
||||
// "fire" / "clear" markers at fixed wallclock offsets so the
|
||||
// operator can flip the trigger boolean during the run.
|
||||
Log($"Probe running for {PumpDuration.TotalSeconds:F0}s. " +
|
||||
$"Observing {TriggerTagReference} alarm transitions. " +
|
||||
"External trigger expected from System Platform script (10s flip cadence).");
|
||||
|
||||
DateTime probeStart = DateTime.UtcNow;
|
||||
DateTime deadline = probeStart + PumpDuration;
|
||||
DateTime nextPoll = probeStart + PollInterval;
|
||||
int pollCount = 0;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
while (PeekMessage(out MSG msg, IntPtr.Zero, 0, 0, PM_REMOVE))
|
||||
{
|
||||
LogIfInteresting(msg);
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessage(ref msg);
|
||||
}
|
||||
// Trigger is supplied externally — a System Platform script
|
||||
// flips TestMachine_001.TestAlarm001 every 10s. The probe
|
||||
// observes only.
|
||||
if (DateTime.UtcNow >= nextPoll)
|
||||
{
|
||||
PollGetStatistics(client, ++pollCount);
|
||||
LogProviders(client, $"poll #{pollCount}");
|
||||
PollAllChannels(client, pollCount);
|
||||
nextPoll = DateTime.UtcNow + PollInterval;
|
||||
}
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
|
||||
Log($"Pump duration {PumpDuration.TotalSeconds:F0}s elapsed; deregistering.");
|
||||
Log($"GetHighPriAlarm tally: ok-with-record={getHighPriOk} threw={getHighPriThrow} " +
|
||||
$"(throws indicate alarm-record marshaling failure; ok=empty record).");
|
||||
|
||||
try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); }
|
||||
catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { client?.Dispose(); } catch { /* swallow */ }
|
||||
if (probeWindow != IntPtr.Zero)
|
||||
{
|
||||
DestroyWindow(probeWindow);
|
||||
probeWindow = IntPtr.Zero;
|
||||
}
|
||||
if (registeredClass != null)
|
||||
{
|
||||
UnregisterClassW(registeredClass, GetModuleHandle(null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string lastStatsSummary = string.Empty;
|
||||
private string lastProvidersSummary = string.Empty;
|
||||
private string lastHighPriSummary = string.Empty;
|
||||
private string lastSfStatsSummary = string.Empty;
|
||||
private int getHighPriOk = 0;
|
||||
private int getHighPriThrow = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Try every read API the AlarmClient exposes and log when its
|
||||
/// output changes. AlarmClient has at least three distinct read
|
||||
/// surfaces — GetStatistics (current-change array), GetHighPriAlarm
|
||||
/// (single-record peek), and the SF (stored filter) family — and any
|
||||
/// of them might be the populated one.
|
||||
/// </summary>
|
||||
private static AlarmRecord NewAlarmRecord()
|
||||
{
|
||||
// The interop's auto-marshal flips DateTime fields to FILETIME on
|
||||
// the way IN as well as OUT. default(DateTime) (year 1) is outside
|
||||
// FILETIME's representable range, so initialize all DateTime fields
|
||||
// to the FILETIME epoch (1601-01-01 UTC) to satisfy the marshaler.
|
||||
AlarmRecord rec = new AlarmRecord();
|
||||
DateTime epoch = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
foreach (var f in typeof(AlarmRecord).GetFields(
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic))
|
||||
{
|
||||
if (f.FieldType == typeof(DateTime))
|
||||
{
|
||||
object boxed = rec;
|
||||
f.SetValue(boxed, epoch);
|
||||
rec = (AlarmRecord)boxed;
|
||||
}
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
private void PollAllChannels(AlarmClient client, int seq)
|
||||
{
|
||||
// Channel A: GetHighPriAlarm — peek highest-priority alarm. Track
|
||||
// outcome state (record/empty/throw) and log every transition AND
|
||||
// total counts at end. The throw correlates with an alarm being
|
||||
// present (AVEVA fills timestamps with sentinel FILETIME values
|
||||
// that crash the .NET marshaler) — useful as a presence signal
|
||||
// even if we can't read the record.
|
||||
try
|
||||
{
|
||||
AlarmRecord rec = NewAlarmRecord();
|
||||
int rc = client.GetHighPriAlarm(ref rec);
|
||||
string desc = rc == 0 ? DescribeAlarmRecord(rec) : "<no record>";
|
||||
string summary = $"rc={rc} {desc}";
|
||||
getHighPriOk++;
|
||||
if (summary != lastHighPriSummary)
|
||||
{
|
||||
Log($"GetHighPriAlarm #{seq}: {summary} (changed; ok={getHighPriOk}, throw={getHighPriThrow})");
|
||||
lastHighPriSummary = summary;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string es = $"{ex.GetType().Name}";
|
||||
getHighPriThrow++;
|
||||
if (es != lastHighPriSummary)
|
||||
{
|
||||
Log($"GetHighPriAlarm #{seq}: threw {es} (changed; ok={getHighPriOk}, throw={getHighPriThrow})");
|
||||
lastHighPriSummary = es;
|
||||
}
|
||||
}
|
||||
|
||||
// Channel C: GetAlarmExtendedRec by index. Try indices 0..3 directly;
|
||||
// populated alarms (if any) appear at low indices.
|
||||
for (int idx = 0; idx <= 2; idx++)
|
||||
{
|
||||
try
|
||||
{
|
||||
AlarmRecord rec = NewAlarmRecord();
|
||||
int rc = client.GetAlarmExtendedRec(idx, ref rec);
|
||||
if (rc == 0)
|
||||
{
|
||||
string desc = DescribeAlarmRecord(rec);
|
||||
Log($"GetAlarmExtendedRec(idx={idx}) #{seq}: rc=0 -> {desc}");
|
||||
break; // log first present record only
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (idx == 0)
|
||||
{
|
||||
Log($"GetAlarmExtendedRec(idx=0) #{seq}: threw {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Channel B: SF — snapshot + GetStatistics + iterate.
|
||||
try
|
||||
{
|
||||
uint numAlarms = 0;
|
||||
int sfCreate = client.SFCreateSnapshot(0, ref numAlarms);
|
||||
int unackRet = 0, unackAlm = 0, ackAlm = 0, others = 0, events = 0, idxNewest = 0;
|
||||
int sfStats = client.SFGetStatistics(
|
||||
ref unackRet, ref unackAlm, ref ackAlm,
|
||||
ref others, ref events, ref idxNewest);
|
||||
string summary = $"SFCreate={sfCreate} numAlarms={numAlarms} " +
|
||||
$"SFStats={sfStats} unackRet={unackRet} unackAlm={unackAlm} " +
|
||||
$"ackAlm={ackAlm} others={others} events={events} idxNewest={idxNewest}";
|
||||
if (summary != lastSfStatsSummary)
|
||||
{
|
||||
Log($"SF channel #{seq}: {summary} (changed)");
|
||||
lastSfStatsSummary = summary;
|
||||
|
||||
// If non-zero, fetch the first record by index via the
|
||||
// standard GetAlarmExtendedRec — after SFCreateSnapshot the
|
||||
// indices reference the snapshot.
|
||||
if (numAlarms > 0)
|
||||
{
|
||||
AlarmRecord rec = new AlarmRecord();
|
||||
int recRc = client.GetAlarmExtendedRec(0, ref rec);
|
||||
Log($" GetAlarmExtendedRec(0) [post-snapshot] rc={recRc} -> {DescribeAlarmRecord(rec)}");
|
||||
}
|
||||
}
|
||||
client.SFDeleteSnapshot();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"SF channel #{seq}: threw {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void LogProviders(AlarmClient client, string when)
|
||||
{
|
||||
try
|
||||
{
|
||||
var providers = new System.Collections.Generic.List<string>();
|
||||
int rc = client.GetProviders(providers);
|
||||
string summary = $"count={providers.Count} list=[{string.Join(", ", providers)}]";
|
||||
if (summary != lastProvidersSummary)
|
||||
{
|
||||
Log($"GetProviders [{when}] -> rc={rc} {summary} (changed)");
|
||||
lastProvidersSummary = summary;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"GetProviders [{when}] threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drive an MxAccess write to <see cref="TriggerTagReference"/> with the
|
||||
/// supplied boolean value. Creates a fresh `LMXProxyServer` COM object,
|
||||
/// registers, adds the item, writes the value, and tears down. Runs on
|
||||
/// the same STA thread the probe uses for the AlarmClient — both COM
|
||||
/// objects share the apartment, which matches the worker's runtime.
|
||||
/// </summary>
|
||||
private void TriggerWriteValue(bool value, int sequence)
|
||||
{
|
||||
object? lmx = null;
|
||||
ILMXProxyServer? srv = null;
|
||||
int handle = 0, itemHandle = 0;
|
||||
try
|
||||
{
|
||||
lmx = new LMXProxyServerClass();
|
||||
srv = (ILMXProxyServer)lmx;
|
||||
handle = srv.Register($"AlarmProbe.Trigger.{sequence}");
|
||||
Log($"Trigger write #{sequence}: Register -> handle={handle}");
|
||||
itemHandle = srv.AddItem(handle, TriggerTagReference);
|
||||
Log($"Trigger write #{sequence}: AddItem('{TriggerTagReference}') -> itemHandle={itemHandle}");
|
||||
|
||||
// First time only: dump every Write* method's signature so we know
|
||||
// which to call. The first attempt hit TargetParameterCountException —
|
||||
// the LMX server has multiple Write variants and we picked wrong.
|
||||
if (sequence == 1)
|
||||
{
|
||||
Log($"Trigger write #{sequence}: enumerating Write* methods on {lmx.GetType().FullName}:");
|
||||
foreach (var m in lmx.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (m.IsSpecialName) continue;
|
||||
if (!m.Name.StartsWith("Write", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
string ps = string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"));
|
||||
Log($" {m.ReturnType.Name} {m.Name}({ps})");
|
||||
}
|
||||
}
|
||||
|
||||
// Late-bind Write — it isn't on ILMXProxyServer's interface but is
|
||||
// exposed by the COM coclass.
|
||||
object[] writeArgs = new object[] { handle, itemHandle, value };
|
||||
object? rv = lmx.GetType().InvokeMember(
|
||||
"Write",
|
||||
BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance,
|
||||
binder: null, target: lmx, args: writeArgs);
|
||||
Log($"Trigger write #{sequence}: Write({TriggerTagReference}={value}) -> rv={rv}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Trigger write #{sequence}: FAILED: {ex.GetType().Name}: {ex.Message}");
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
Log($" inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (srv != null && itemHandle != 0) { srv.RemoveItem(handle, itemHandle); }
|
||||
if (srv != null && handle != 0) { srv.Unregister(handle); }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Trigger write #{sequence}: cleanup failure: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
if (lmx != null && System.Runtime.InteropServices.Marshal.IsComObject(lmx))
|
||||
{
|
||||
try { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(lmx); }
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void PollGetStatistics(AlarmClient client, int seq)
|
||||
{
|
||||
try
|
||||
{
|
||||
int percent = 0, total = 0, active = 0, suppressed = 0;
|
||||
int suppressedFilters = 0, newAlarms = 0, changes = 0;
|
||||
int[] codes = Array.Empty<int>();
|
||||
int[] positions = Array.Empty<int>();
|
||||
int[] handles = Array.Empty<int>();
|
||||
int rc = client.GetStatistics(
|
||||
ref percent, ref total, ref active, ref suppressed,
|
||||
ref suppressedFilters, ref newAlarms, ref changes,
|
||||
ref codes, ref positions, ref handles);
|
||||
string codesStr = codes != null ? string.Join(",", codes) : "<null>";
|
||||
string posStr = positions != null ? string.Join(",", positions) : "<null>";
|
||||
string handlesStr = handles != null ? string.Join(",", handles) : "<null>";
|
||||
int posLen = positions?.Length ?? 0;
|
||||
|
||||
// Suppress duplicate-summary spam — only log when interesting
|
||||
// state-change is observed. The "interesting" digest excludes
|
||||
// percent (always 100 at steady state).
|
||||
string summary = $"total={total} active={active} suppressed={suppressed} " +
|
||||
$"new={newAlarms} changes={changes} codes=[{codesStr}] " +
|
||||
$"positions=[{posStr}] handles=[{handlesStr}]";
|
||||
if (summary != lastStatsSummary)
|
||||
{
|
||||
Log($"GetStatistics #{seq} rc={rc} pct={percent} {summary} (changed)");
|
||||
lastStatsSummary = summary;
|
||||
}
|
||||
|
||||
// Always fetch records when positions has entries — records
|
||||
// change content even when count stays the same.
|
||||
if (posLen > 0 && positions != null)
|
||||
{
|
||||
for (int i = 0; i < Math.Min(posLen, 4); i++)
|
||||
{
|
||||
int idx = positions[i];
|
||||
AlarmRecord rec = new AlarmRecord();
|
||||
int recRc = client.GetAlarmExtendedRec(idx, ref rec);
|
||||
Log($" GetAlarmExtendedRec(idx={idx}) rc={recRc} -> " +
|
||||
DescribeAlarmRecord(rec));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"GetStatistics #{seq} threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeAlarmRecord(AlarmRecord rec)
|
||||
{
|
||||
// Reflect over the record's public properties so we don't have to
|
||||
// guess the field shape — the discovery probe already showed it has
|
||||
// ar_AlarmName / ar_Provider / ar_Group / ar_AlmTransition / etc.
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("{ ");
|
||||
bool first = true;
|
||||
foreach (var prop in rec.GetType().GetProperties(
|
||||
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance))
|
||||
{
|
||||
try
|
||||
{
|
||||
object? v = prop.GetValue(rec);
|
||||
string vs = v?.ToString() ?? "<null>";
|
||||
if (vs.Length > 50) vs = vs.Substring(0, 47) + "...";
|
||||
if (!first) sb.Append(", ");
|
||||
sb.Append($"{prop.Name}={vs}");
|
||||
first = false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// skip failing accessors
|
||||
}
|
||||
}
|
||||
sb.Append(" }");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void LogIfInteresting(MSG m)
|
||||
{
|
||||
// Filter out the highest-volume noise (timer ticks, paint, mouse moves
|
||||
// from a desktop session). Keep WM_USER..WM_APP+ entirely; those are
|
||||
// the candidates for the AVEVA-registered message.
|
||||
const uint WM_PAINT = 0x000F;
|
||||
const uint WM_TIMER = 0x0113;
|
||||
const uint WM_MOUSEMOVE = 0x0200;
|
||||
const uint WM_NCMOUSEMOVE = 0x00A0;
|
||||
if (m.message == WM_PAINT || m.message == WM_TIMER ||
|
||||
m.message == WM_MOUSEMOVE || m.message == WM_NCMOUSEMOVE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string interpreted = InterpretMessageId(m.message);
|
||||
Log(string.Format(
|
||||
"WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8} hwnd=0x{4:X}",
|
||||
m.message, interpreted,
|
||||
m.wParam.ToInt64() & 0xFFFFFFFF, m.lParam.ToInt64() & 0xFFFFFFFF,
|
||||
m.hwnd.ToInt64()));
|
||||
}
|
||||
|
||||
private static string InterpretMessageId(uint id)
|
||||
{
|
||||
if (id < 0x0400) return "WM_<system>";
|
||||
if (id < 0x8000) return $"WM_USER+0x{id - 0x0400:X4}";
|
||||
if (id < 0xC000) return $"WM_APP+0x{id - 0x8000:X4}";
|
||||
return $"RegisterWindowMessage_0x{id:X4}";
|
||||
}
|
||||
|
||||
private IntPtr ProbeWndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
// Log every WM that lands on the probe window itself.
|
||||
string interpreted = InterpretMessageId(msg);
|
||||
Log(string.Format(
|
||||
"WndProc WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8}",
|
||||
msg, interpreted,
|
||||
wParam.ToInt64() & 0xFFFFFFFF, lParam.ToInt64() & 0xFFFFFFFF));
|
||||
return DefWindowProcW(hWnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
private void Log(string line)
|
||||
{
|
||||
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (wndProcHandle.IsAllocated) wndProcHandle.Free();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace MxGateway.Worker.Tests;
|
||||
|
||||
/// <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>();
|
||||
|
||||
public AlarmsLiveSmokeTests(ITestOutputHelper output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
[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();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using WNWRAPCONSUMERLib;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace MxGateway.Worker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime probe — instantiate AVEVA's standalone wnwrapConsumer COM
|
||||
/// class (CLSID 7AB52E5F-36B2-4A30-AE46-952A746F667C, registered at
|
||||
/// C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll),
|
||||
/// subscribe to the dev rig's `\\<machine>\Galaxy!DEV` provider, and
|
||||
/// poll <c>GetXmlCurrentAlarms2</c> while a System Platform script flips
|
||||
/// <c>TestMachine_001.TestAlarm001</c> every 10s. The XML payload bypasses
|
||||
/// the FILETIME→DateTime auto-marshaling that crashes
|
||||
/// <c>aaAlarmManagedClient.AlarmClient.GetHighPriAlarm</c>.
|
||||
///
|
||||
/// Skip-gated; flip Skip=null to run on the dev rig.
|
||||
/// </summary>
|
||||
public sealed class WnWrapConsumerProbeTests
|
||||
{
|
||||
private static readonly string MachineName = Environment.MachineName;
|
||||
private static readonly string SubscriptionExpression =
|
||||
$@"\\{MachineName}\Galaxy!DEV";
|
||||
|
||||
// XML query form — per WIN-911 / ArchestrA reference. NODE is the
|
||||
// machine, PROVIDER is the literal "Galaxy", GROUP is the area.
|
||||
private static readonly string XmlAlarmQuery =
|
||||
"<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">" +
|
||||
"<QUERY>" +
|
||||
$"<NODE>{Environment.MachineName}</NODE>" +
|
||||
"<PROVIDER>Galaxy</PROVIDER>" +
|
||||
"<GROUP>DEV</GROUP>" +
|
||||
"</QUERY>" +
|
||||
"</QUERIES>";
|
||||
|
||||
private const int MaxAlarmsPerFetch = 100;
|
||||
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly ITestOutputHelper output;
|
||||
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
|
||||
private readonly Stopwatch elapsed = Stopwatch.StartNew();
|
||||
|
||||
public WnWrapConsumerProbeTests(ITestOutputHelper output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture wnwrapConsumer XML alarm output. Verified working 2026-05-01.")]
|
||||
public void ProbeWnWrapConsumer_OnDevRig_LogsXmlAlarmStream()
|
||||
{
|
||||
Exception? threadException = null;
|
||||
var done = new ManualResetEventSlim(false);
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try { RunProbe(); }
|
||||
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 RunProbe()
|
||||
{
|
||||
wwAlarmConsumerClass? client = null;
|
||||
try
|
||||
{
|
||||
Log("Creating wwAlarmConsumerClass via CoCreateInstance...");
|
||||
client = new wwAlarmConsumerClass();
|
||||
Log($"Instantiated. RuntimeType={client.GetType().FullName}");
|
||||
|
||||
// Lifecycle: per AlarmClientDiscovery.md finding, InitializeConsumer
|
||||
// MUST precede RegisterConsumer for the alarm provider to become
|
||||
// visible. The wnwrap surface mirrors that requirement.
|
||||
try
|
||||
{
|
||||
int init = client.InitializeConsumer("MxGatewayProbe.WnWrap");
|
||||
Log($"InitializeConsumer -> {init}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"InitializeConsumer threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// hWnd=0 — XML pull-based; no message pump needed.
|
||||
int reg = client.RegisterConsumer(
|
||||
hWnd: 0,
|
||||
szProductName: "MxGatewayProbe",
|
||||
szApplicationName: "MxGatewayProbe.WnWrap",
|
||||
szVersion: "1.0");
|
||||
Log($"RegisterConsumer(hWnd=0) -> {reg}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"RegisterConsumer threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Try both subscription mechanisms: classic Subscribe (canonical
|
||||
// scope from prior aaAlarmManagedClient probe), and
|
||||
// SetXmlAlarmQuery (the wnwrap-native filter format).
|
||||
try
|
||||
{
|
||||
int sub = client.Subscribe(
|
||||
szSubscription: SubscriptionExpression,
|
||||
wFromPri: 1,
|
||||
wToPri: 999,
|
||||
QueryType: eQueryType.qtSummary,
|
||||
SortFlags: eSortFlags.sfReturnNewestFirst,
|
||||
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
||||
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
||||
Log($"Subscribe('{SubscriptionExpression}') -> {sub}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Subscribe threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Log($"SetXmlAlarmQuery payload: {XmlAlarmQuery}");
|
||||
client.SetXmlAlarmQuery(XmlAlarmQuery);
|
||||
Log("SetXmlAlarmQuery -> ok");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"SetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Echo the query back so we can confirm what the consumer is
|
||||
// actually filtering on (provider may rewrite or reject some
|
||||
// attributes silently).
|
||||
try
|
||||
{
|
||||
object echo = string.Empty;
|
||||
client.GetXmlAlarmQuery(out echo);
|
||||
Log($"GetXmlAlarmQuery (round-trip) -> {Truncate(echo?.ToString() ?? "<null>", 600)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"GetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Pump phase: poll GetXmlCurrentAlarms2 every PollInterval; log on
|
||||
// every change in payload. Run for PumpDuration. The user's flip
|
||||
// script writes TestMachine_001.TestAlarm001 every 10s; expect at
|
||||
// least 2-3 transitions over a 30s window.
|
||||
Log($"Polling GetXmlCurrentAlarms2 every {PollInterval.TotalMilliseconds:F0}ms for {PumpDuration.TotalSeconds:F0}s.");
|
||||
DateTime deadline = DateTime.UtcNow + PumpDuration;
|
||||
DateTime nextPoll = DateTime.UtcNow;
|
||||
int pollCount = 0;
|
||||
string lastV2 = string.Empty;
|
||||
string lastV1 = string.Empty;
|
||||
int v2Ok = 0, v2Throw = 0, v1Ok = 0, v1Throw = 0;
|
||||
int statsOk = 0, statsThrow = 0;
|
||||
string lastStats = string.Empty;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (DateTime.UtcNow >= nextPoll)
|
||||
{
|
||||
pollCount++;
|
||||
|
||||
// V2 channel.
|
||||
try
|
||||
{
|
||||
object xml2 = string.Empty;
|
||||
client.GetXmlCurrentAlarms2(MaxAlarmsPerFetch, out xml2);
|
||||
v2Ok++;
|
||||
string s = xml2?.ToString() ?? "<null>";
|
||||
if (s != lastV2)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms2 #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
|
||||
lastV2 = s;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
v2Throw++;
|
||||
string es = $"{ex.GetType().Name}: {ex.Message}";
|
||||
if (es != lastV2)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms2 #{pollCount} threw: {es}");
|
||||
lastV2 = es;
|
||||
}
|
||||
}
|
||||
|
||||
// V1 channel — different vtable slot; either may be the
|
||||
// populated one in this AVEVA build.
|
||||
try
|
||||
{
|
||||
object xml1 = string.Empty;
|
||||
client.GetXmlCurrentAlarms(MaxAlarmsPerFetch, out xml1);
|
||||
v1Ok++;
|
||||
string s = xml1?.ToString() ?? "<null>";
|
||||
if (s != lastV1)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
|
||||
lastV1 = s;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
v1Throw++;
|
||||
string es = $"{ex.GetType().Name}: {ex.Message}";
|
||||
if (es != lastV1)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms #{pollCount} threw: {es}");
|
||||
lastV1 = es;
|
||||
}
|
||||
}
|
||||
|
||||
// Stats channel — heartbeat + active-count even if the XML
|
||||
// calls are dry, this surfaces whether wnwrap sees any
|
||||
// alarms in the subscribed scope at all.
|
||||
try
|
||||
{
|
||||
int pct, total, active, newAlms, changes;
|
||||
client.GetStatistics(
|
||||
out pct, out total, out active, out newAlms, out changes,
|
||||
IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
||||
statsOk++;
|
||||
string statsSummary = $"pct={pct} total={total} active={active} new={newAlms} changes={changes}";
|
||||
if (statsSummary != lastStats)
|
||||
{
|
||||
Log($"GetStatistics #{pollCount} (CHANGED): {statsSummary}");
|
||||
lastStats = statsSummary;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
statsThrow++;
|
||||
Log($"GetStatistics #{pollCount} threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
nextPoll = DateTime.UtcNow + PollInterval;
|
||||
}
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
Log($"Pump done. Tally: v2 ok={v2Ok} threw={v2Throw}, v1 ok={v1Ok} threw={v1Throw}, stats ok={statsOk} threw={statsThrow}");
|
||||
|
||||
try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); }
|
||||
catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
|
||||
|
||||
try { int uninit = client.UninitializeConsumer(); Log($"UninitializeConsumer -> {uninit}"); }
|
||||
catch (Exception ex) { Log($"UninitializeConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (client != null && Marshal.IsComObject(client))
|
||||
{
|
||||
try { Marshal.FinalReleaseComObject(client); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Log(string line)
|
||||
{
|
||||
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s) || s.Length <= max) return s ?? string.Empty;
|
||||
return s.Substring(0, max) + $"…[+{s.Length - max} chars]";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user