From 12881ca791fd86067422964277427a925a0cea87 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 1 May 2026 07:05:47 -0400 Subject: [PATCH] =?UTF-8?q?docs+test:=20live=20AlarmClient=20WM=20probe=20?= =?UTF-8?q?=E2=80=94=20heartbeat-only,=20hWnd=20not=20used?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs as a Skip-gated runtime probe. Run on the dev rig 2026-05-01 against the live AVEVA install (Galaxy reachable, no manual alarm fired). Findings: - RegisterConsumer(hWnd, ...) and Subscribe("\Galaxy!", ...) both return 0 (success). Calls are valid against the deployed assembly. - A registered-message-class WM (ID 0xC275 in this OS session) fires every ~1 second after Subscribe completes. Constant wParam=0x1100, constant lParam=0x079E46D8 — looks like a heartbeat / keepalive, not a per-change notification. - Critically, this WM is delivered to AVEVA's own internal window (hwnd=0x18032E), NOT to the consumer hWnd we registered. The consumer window receives only the standard WM_CREATE / WM_DESTROY sequence; no AVEVA traffic in between. This invalidates the WM_APP-pump design previously documented. The hWnd parameter to RegisterConsumer appears to be a registration identity only — AVEVA's notification path runs entirely against AVEVA's own internal window. Two viable A.2 designs replace the previous one: 1. Polling. Call GetStatistics on a 500ms timer in the worker's STA and react to whatever change set it reports. No window plumbing needed. Latency floor = poll period. Matches AVEVA's own internal heartbeat cadence. 2. Hook AVEVA's internal window. Discover AVEVA's own hwnd, SetWindowSubclass on it, intercept WM 0xC275 on AVEVA's thread. Higher fidelity, lower latency, but invasive and fragile across AVEVA upgrades — likely a non-starter. Recommendation in docs/AlarmClientDiscovery.md is option 1 (polling) unless a follow-up probe with a real fired alarm shows AVEVA does post change-specific WMs to a different hWnd. Open follow-up probes documented: - Fire a real Galaxy alarm during pump and check whether WM 0xC275 cadence changes or GetStatistics returns non-empty arrays. - GetStatistics threading affinity test. - Hook AVEVA's internal window 0x18032E. - Decompile aaAlarmManagedClient IL for RegisterConsumer to find whether WNAL_Register's callback surface is wrapped. Test project changes: - Added Reference to aaAlarmManagedClient + IAlarmMgrDataProvider (Private=true so the DLL gets copied into bin for test load). - Test-suite-wide: 127 real tests still pass; both alarm-related Skip-gated tests skip cleanly. Code change to the probe is additive — the worker is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/AlarmClientDiscovery.md | 118 +++++-- .../AlarmClientWmProbeTests.cs | 328 ++++++++++++++++++ .../MxGateway.Worker.Tests.csproj | 13 + 3 files changed, 431 insertions(+), 28 deletions(-) create mode 100644 src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md index ef0c95a..3c067d5 100644 --- a/docs/AlarmClientDiscovery.md +++ b/docs/AlarmClientDiscovery.md @@ -87,35 +87,97 @@ a result — `RaiseAlarmRecordReceived` is `internal` for tests and never gets invoked at runtime. Until A.2 lands a WM_APP pump, `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` cannot carry events. -## Implications for A.2 +## Live runtime probe — 2026-05-01 -The original plan banner was right: A.2 needs a hidden message-only -window inside the worker's STA whose hWnd is passed to -`RegisterConsumer`, with a `WindowProc` that intercepts the AVEVA -WM_APP message and routes change-enumeration into -`MxAccessAlarmEventSink.EnqueueTransition`. Open questions before -implementation: +`MxGateway.Worker.Tests.AlarmClientWmProbeTests.ProbeAlarmClientWmMessages` +is a Skip-gated runtime probe that creates a real message-only +window, calls `AlarmClient.RegisterConsumer(hWnd, …)` + +`Subscribe(@"\Galaxy!", …)`, and pumps for 20s while logging every +window message that arrives. Run results below — this turned the +"WM_APP pump" design assumption upside down. -1. **WM_APP message ID.** Not in the public surface — needs either - AVEVA's C++ Toolkit reference (canonical doc per `gateway.md`) or - a runtime probe (subclass a window, log every WM arriving while a - live alarm is fired, identify the AVEVA one). Worth doing once on - the dev rig and checking the result in. -2. **`wParam` / `lParam` semantics.** Probably none — the pattern is - "got poked; pull state via `GetStatistics`." Confirm during the - probe. -3. **Threading.** AVEVA almost certainly delivers the WM on the - thread that owns the window. The worker's STA is the natural - home; the existing `StaRuntime` already runs a pump there. If - AVEVA assumes a UI thread (MTA-incompatible call paths inside - `GetStatistics`), the alarm path may need its own STA. -4. **Subscription scope.** `AlarmClient.Subscribe(szSubscription, - …)` takes an AVEVA-syntax string for the alarm provider (e.g. - `"\Galaxy!"` for a whole Galaxy or `"\Galaxy!Group.Tag"` for a - subset). The configured Galaxy name is already known to the - worker via the existing data session — reuse it. +**`RegisterConsumer` and `Subscribe` both returned 0 (success).** The +calls are valid against the deployed assembly; no parameter pinning +needed. + +**A registered-message-class WM (ID `0xC275` in this OS session) +fired every ~1s after `Subscribe` completed.** Constant +`wParam = 0x00001100`, constant `lParam = 0x079E46D8` (looks like a +stable pointer into AVEVA-internal state) for all 20 hits. The +constant payload across hits with no Galaxy alarm being fired +suggests this is a **heartbeat/keepalive**, not a per-change +notification. + +**Critically: this WM is delivered to AVEVA's own internal window +(`hwnd=0x18032E`) — NOT to the consumer's `hWnd` we passed in.** The +consumer window's `WndProc` received only the standard creation +sequence (`WM_GETMINMAXINFO`, `WM_NCCREATE`, `WM_NCCALCSIZE`, +`WM_CREATE`) and the destruction sequence (`WM_NCDESTROY`, +`WM_DESTROY`, `WM_NCCALCSIZE`) — nothing in between. AVEVA's +notification path runs entirely against AVEVA's internal window; +it never forwards to the user-supplied hWnd. + +The message ID itself is dynamic (a `RegisterWindowMessage` +allocation in the >= 0xC000 range), so it cannot be hard-coded — +each consumer process must call `RegisterWindowMessage` with the +correct *string* and use whatever ID the OS returns. + +## What this means for A.2 + +The "WM_APP pump on the user hWnd" design — what the original plan +banner described and what the previous version of this doc +recommended — does not match how AVEVA actually delivers +notifications. The hWnd parameter to `RegisterConsumer` does not +appear to receive any of AVEVA's alarm traffic; it's likely used +only as a registration identity (and perhaps as a parent for modal +dialogs). + +Two viable A.2 designs given the probe data: + +1. **Polling.** Just call `GetStatistics` on a timer (e.g. every + 500ms in the worker's STA) and react to the change set it + reports. No window plumbing needed. Trade-off: latency floor = + poll period; modest CPU floor because the call is cheap. Matches + the heartbeat-style WM 0xC275 semantics — AVEVA itself runs a + poll loop internally. +2. **Hook AVEVA's internal window.** Discover AVEVA's own window + (`hwnd=0x18032E` in the probe), `SetWindowsHookEx` or + `SetWindowSubclass` on it, and intercept WM 0xC275 on AVEVA's + thread. Higher fidelity, near-zero latency, but invasive, + fragile across AVEVA upgrades, and requires running on the same + process / thread as the AVEVA window. Probably a non-starter + without further AVEVA documentation. + +**Recommendation:** the polling path (option 1) is cheaper to +implement, more robust against AVEVA-internal change, and +acceptable for a typical alarm cadence. The worker's existing STA +already provides a thread-affinitized timer surface. The unanswered +question is whether `GetStatistics` can be safely called outside +AVEVA's own message-pump thread — confirmable by extending the +probe to fire `GetStatistics` on its own thread and check the +result. + +## Open follow-up probes + +Each can be added to `AlarmClientWmProbeTests` as a separate +Skip-gated test: + +1. **Fire a real Galaxy alarm during the pump window.** Confirms + whether the WM 0xC275 cadence changes (becomes per-change rather + than periodic) and whether `GetStatistics` returns a non-empty + `ChangeCodes / ChangePos / hAlarm` triple. +2. **Call `GetStatistics` on a different thread from the + `RegisterConsumer` thread** to test threading affinity. +3. **Hook AVEVA's internal window** to log what WMs it actually + processes (would resolve option 2 above). +4. **Decompile `aaAlarmManagedClient.dll`'s IL** for the + `RegisterConsumer` method to find what `RegisterWindowMessage` + string is used and whether there's a callback-registration + surface on `WNAL_Register` that the managed client wraps. The + alarmlst.dll strings (`WNAL_CallBack`, "Invalid callbacks" error) + suggest the underlying C API takes callbacks, but the managed + wrapper exposes none of them. PR A.5's `Subscribe` / `AcknowledgeByGuid` / `SnapshotActiveAlarms` -are correct — they're pull-style and don't depend on the missing -event surface. The event-subscription wiring is what has to be -replaced. +are correct — they're pull-style and don't depend on the +notification mechanism. diff --git a/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs new file mode 100644 index 0000000..a9ba7b3 --- /dev/null +++ b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using AlarmMgrDataProviderCOM; +using aaAlarmManagedClient; +using Xunit.Abstractions; + +namespace MxGateway.Worker.Tests; + +/// +/// 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 +/// wParam/lParam semantics on each. +/// +/// Skip-gated by default; flip Skip=null and run against the live dev +/// rig to capture output. Requires: +/// +/// A reachable Galaxy with at least one alarmable object. +/// The configured Galaxy expression below to match a real provider (default "\\Galaxy" — adjust if needed). +/// 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. +/// +/// +public sealed class AlarmClientWmProbeTests : IDisposable +{ + // Probe configuration. Override in the constructor below if needed. + private const string SubscriptionExpression = @"\Galaxy!"; + private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(20); + + [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 log = new ConcurrentQueue(); + 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 (with live Galaxy) to capture AVEVA WM_APP message IDs")] + public void ProbeAlarmClientWmMessages() + { + // 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(); + int register = client.RegisterConsumer( + hWnd: probeWindow.ToInt32(), + szProductName: "AlarmProbe", + szApplicationName: "AlarmProbe.Tests", + szVersion: "1.0", + bRetainHiddenAlarms: false); + Log($"RegisterConsumer -> {register}"); + + int subscribe = client.Subscribe( + szSubscription: SubscriptionExpression, + wFromPri: 1, wToPri: 999, + QueryType: eQueryType.qtSummary, + SortFlags: eSortFlags.sfReturnNewestFirst, + FilterMask: eAlarmFilterState.asNone, + FilterSpecification: eAlarmFilterState.asNone); + Log($"Subscribe('{SubscriptionExpression}') -> {subscribe}"); + + // 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). + DateTime deadline = DateTime.UtcNow + PumpDuration; + while (DateTime.UtcNow < deadline) + { + while (PeekMessage(out MSG msg, IntPtr.Zero, 0, 0, PM_REMOVE)) + { + LogIfInteresting(msg); + TranslateMessage(ref msg); + DispatchMessage(ref msg); + } + Thread.Sleep(10); + } + + Log($"Pump duration {PumpDuration.TotalSeconds:F0}s elapsed; deregistering."); + + 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 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_"; + 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(); + } +} diff --git a/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj b/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj index 47796e3..bb2f949 100644 --- a/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj +++ b/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj @@ -25,4 +25,17 @@ + + + C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll + true + false + + + C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll + true + false + + +