probe: GetProviders=0 — alarm path upstream-blocked on dev rig

Extended AlarmClientWmProbeTests to call AlarmClient.GetProviders
after RegisterConsumer. Run 2026-05-01:

  GetProviders -> rc=0 count=0 list=[]

Zero alarm providers visible to the consumer. This explains every
preceding probe run — no providers means no alarm events,
regardless of subscription expression or value writes upstream.
Even with a System Platform script flipping
TestMachine_001.TestAlarm001 every 10s during the run,
GetStatistics reported no transitions, no positions[] entries,
no field changes after t=0.85s.

Possible causes (dev-rig configuration, not code):

1. No $Alarm extension on the test bool — flipping the value
   writes a value but doesn't fire an alarm.
2. AVEVA alarm-manager service (aaAlarmMgr or equivalent) not
   running on this rig.
3. Process security context — providers registered under a
   service account aren't visible to a consumer running under
   a normal user account.

A.2 implementation is blocked on this until at least one provider
is visible. Once a provider exists, the polling-vs-callback
question is answerable in one probe run; without a provider both
paths return the same "nothing happening" answer.

Probe changes:

- Added in-process MxAccess Write attempt (TriggerWriteValue) —
  hit TargetParameterCountException so the Write signature is
  not (handle, item, value); reflection diag added but not
  resolved. Now disabled in favor of external trigger.
- Added GetProviders enumeration after RegisterConsumer.
- Removed firePrint/clearPrint markers; probe is observe-only.
- Added ArchestrA.MxAccess reference to the test project.

Also updated docs/AlarmClientDiscovery.md with the
alarm-provider-visibility section explaining what's blocked
and why.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-01 07:37:15 -04:00
parent 3ff4969224
commit f4423dfb6d
3 changed files with 193 additions and 19 deletions
@@ -4,8 +4,11 @@ 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;
@@ -30,7 +33,13 @@ 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);
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(
@@ -113,7 +122,7 @@ public sealed class AlarmClientWmProbeTests : IDisposable
this.output = output;
}
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (with live Galaxy) to capture AVEVA WM_APP message IDs + GetStatistics polling results")]
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture alarm-path behavior")]
public void ProbeAlarmClientWmMessages()
{
// 1. Pre-resolve a few candidate RegisterWindowMessage strings so any
@@ -230,6 +239,19 @@ public sealed class AlarmClientWmProbeTests : IDisposable
bRetainHiddenAlarms: false);
Log($"RegisterConsumer -> {register}");
// Discover what providers AVEVA sees before subscribing, so we
// can spot a wrong subscription expression up front.
try
{
var providers = new System.Collections.Generic.List<string>();
int gp = client.GetProviders(providers);
Log($"GetProviders -> rc={gp} count={providers.Count} list=[{string.Join(", ", providers)}]");
}
catch (Exception ex)
{
Log($"GetProviders threw: {ex.GetType().Name}: {ex.Message}");
}
int subscribe = client.Subscribe(
szSubscription: SubscriptionExpression,
wFromPri: 1, wToPri: 999,
@@ -241,13 +263,19 @@ public sealed class AlarmClientWmProbeTests : IDisposable
// 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). Every ~2s also call
// GetStatistics and snapshot up to N records, to test the
// polling design — if Galaxy has any active alarms or any
// have changed since Subscribe, we'll see them here.
DateTime deadline = DateTime.UtcNow + PumpDuration;
DateTime nextPoll = DateTime.UtcNow + TimeSpan.FromSeconds(2);
// 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))
@@ -256,10 +284,13 @@ public sealed class AlarmClientWmProbeTests : IDisposable
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);
nextPoll = DateTime.UtcNow + TimeSpan.FromSeconds(2);
nextPoll = DateTime.UtcNow + PollInterval;
}
Thread.Sleep(10);
}
@@ -284,6 +315,81 @@ public sealed class AlarmClientWmProbeTests : IDisposable
}
}
private string lastStatsSummary = string.Empty;
/// <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
@@ -301,19 +407,31 @@ public sealed class AlarmClientWmProbeTests : IDisposable
string posStr = positions != null ? string.Join(",", positions) : "<null>";
string handlesStr = handles != null ? string.Join(",", handles) : "<null>";
int posLen = positions?.Length ?? 0;
Log($"GetStatistics #{seq} rc={rc} pct={percent} total={total} active={active} " +
$"suppressed={suppressed} suppressedFilters={suppressedFilters} new={newAlarms} changes={changes} " +
$"codes=[{codesStr}] positions=[{posStr}] handles=[{handlesStr}]");
// If positions has entries, fetch one record so we see the
// record-shape AVEVA exposes for a real alarm.
// 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)
{
int idx = positions[0];
AlarmRecord rec = new AlarmRecord();
int recRc = client.GetAlarmExtendedRec(idx, ref rec);
Log($" GetAlarmExtendedRec(idx={idx}) rc={recRc} -> " +
DescribeAlarmRecord(rec));
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)
@@ -26,6 +26,11 @@
</ItemGroup>
<ItemGroup>
<Reference Include="ArchestrA.MxAccess">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</HintPath>
<Private>true</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
<Reference Include="aaAlarmManagedClient">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll</HintPath>
<Private>true</Private>