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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user