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:
Joseph Doherty
2026-05-20 09:46:47 -04:00
parent 1cd51bbda3
commit a0203503a7
122 changed files with 8723 additions and 757 deletions
@@ -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>\\&lt;machine&gt;\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 `\\&lt;machine&gt;\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]";
}
}