docs+test: live AlarmClient WM probe — heartbeat-only, hWnd not used

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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-01 07:05:47 -04:00
parent 6e356da092
commit 12881ca791
3 changed files with 431 additions and 28 deletions
+90 -28
View File
@@ -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.
@@ -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;
/// <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.
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<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 (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_<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();
}
}
@@ -25,4 +25,17 @@
<ProjectReference Include="..\MxGateway.Worker\MxGateway.Worker.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="aaAlarmManagedClient">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll</HintPath>
<Private>true</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
<Reference Include="IAlarmMgrDataProvider">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll</HintPath>
<Private>true</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
</ItemGroup>
</Project>