alarms-over-gateway: full pipeline (wnwrap consumer + dispatcher + IPC + auto-subscribe + ack-by-name + live smoke) #118

Merged
dohertj2 merged 16 commits from docs/alarm-client-wm-app-finding into main 2026-05-01 12:31:29 -04:00
3 changed files with 431 additions and 28 deletions
Showing only changes of commit 12881ca791 - Show all commits
+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>