A.3 (live smoke): full alarms-over-gateway pipeline verified end-to-end
Skip-gated AlarmsLiveSmokeTests.Alarms_full_pipeline_round_trip ran
against the dev rig with the flip script firing
TestMachine_001.TestAlarm001 every 10s. Verified:
- Subscribe + 1st PollOnce yield real transition events
- Field-by-field decode correct (provider, group, tag, severity,
UTC timestamp, comment, type)
- SnapshotActiveAlarms reflects current state
- AcknowledgeByName(real identity) -> rc=0
- Pipeline keeps streaming transitions on the 10s cadence post-ack
Three production quirks surfaced and were fixed in
WnWrapAlarmConsumer:
1. SetXmlAlarmQuery is mandatory for reads. Skipping it (per the
earlier discovery-doc recommendation) makes the first
GetXmlCurrentAlarms2 fail with E_FAIL. The doc's claim that the
call is unnecessary because the round-trip echo is mangled was
wrong — mangled echo or not, the call is required.
2. SetXmlAlarmQuery breaks AlarmAckByName on the same consumer
instance (returns -55). Workaround: provision a parallel
"ack-only" wnwrap consumer that runs Initialize → Register →
Subscribe via the v1-prefixed methods, no SetXmlAlarmQuery.
Production WnWrapAlarmConsumer now holds two COM clients;
AcknowledgeByName always dispatches through the ack-only one.
3. AlarmAckByName has v2 (8-arg) and v1 (6-arg) overloads. The v2
8-arg overload returns -55 on this AVEVA build (apparently a
stub); the v1 6-arg overload works. Production now calls the
6-arg overload, discarding the proto's operator_domain and
operator_full_name fields. The proto contract keeps both for
forward-compat if AVEVA fixes the v2 method.
Bonus finding (not fixed here): AlarmAckByGUID throws
NotImplementedException on wnwrap. Reference→GUID lookup that we
initially planned to plumb is therefore not viable; all acks must
go through AlarmAckByName. WorkerAlarmRpcDispatcher.AcknowledgeAsync
already routes references through the by-name path, so this only
affects the GUID-input branch (which the worker tries first if the
input parses as a GUID — that branch will surface
NotImplementedException as MxaccessFailure if a client supplies one).
Threading caveat: wnwrap is ThreadingModel=Apartment, so the
consumer's internal Timer (firing on threadpool threads) blocks on
cross-apartment marshaling without an STA message pump. The smoke
test sidesteps this with pollIntervalMilliseconds=0 (Timer disabled)
+ manual PollOnce calls from the test STA. Production hosting will
route polls through the worker's StaRuntime in a follow-up; PollOnce
is now public so the wire-up is straightforward.
Test counts after this slice:
Worker: 195 pass / 4 skipped (live probes incl. new live smoke) /
1 pre-existing structure-fail (untouched)
Server: 308 pass / 0 fail
Solution builds clean.
docs/AlarmClientDiscovery.md "Live smoke-test discoveries" section
records all five findings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,8 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
private readonly int maxAlarmsPerFetch;
|
||||
|
||||
private wwAlarmConsumerClass? client;
|
||||
private wwAlarmConsumerClass? ackClient;
|
||||
private string subscriptionExpression = string.Empty;
|
||||
private Timer? pollTimer;
|
||||
private bool subscribed;
|
||||
private bool disposed;
|
||||
@@ -66,16 +68,23 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — inject a pre-created COM client and tune the poll cadence.</summary>
|
||||
internal WnWrapAlarmConsumer(
|
||||
/// <summary>
|
||||
/// Test seam / explicit construction — inject a pre-created COM
|
||||
/// client and tune the poll cadence. <c>pollIntervalMilliseconds == 0</c>
|
||||
/// disables the internal <see cref="Timer"/> entirely; the caller
|
||||
/// must drive <see cref="PollOnce"/> manually (used by hosts that
|
||||
/// marshal polls onto a foreign STA, and by live smoke tests that
|
||||
/// pump from the STA they own).
|
||||
/// </summary>
|
||||
public WnWrapAlarmConsumer(
|
||||
wwAlarmConsumerClass client,
|
||||
int pollIntervalMilliseconds,
|
||||
int maxAlarmsPerFetch)
|
||||
{
|
||||
this.client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
this.pollIntervalMs = pollIntervalMilliseconds > 0
|
||||
? pollIntervalMilliseconds
|
||||
: DefaultPollIntervalMilliseconds;
|
||||
this.pollIntervalMs = pollIntervalMilliseconds < 0
|
||||
? DefaultPollIntervalMilliseconds
|
||||
: pollIntervalMilliseconds;
|
||||
this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
|
||||
? maxAlarmsPerFetch
|
||||
: DefaultMaxAlarmsPerFetch;
|
||||
@@ -104,9 +113,14 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
wwAlarmConsumerClass com = client
|
||||
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
|
||||
// Per AlarmClientDiscovery.md: InitializeConsumer MUST precede
|
||||
// RegisterConsumer for the alarm provider chain to become visible.
|
||||
int init = com.InitializeConsumer(DefaultApplicationName);
|
||||
// Use the IwwAlarmConsumer (v1) prefix-named methods for the
|
||||
// lifecycle. Empirically (live dev-rig 2026-05-01) this is the
|
||||
// only path that lets AlarmAckByName succeed afterwards. The
|
||||
// v2 Initialize/Register/Subscribe methods on the class
|
||||
// succeed (return 0) but acks against that consumer state
|
||||
// return -55. The v1 prefix path is what WIN-911-style code
|
||||
// uses against the same wnwrap library.
|
||||
int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName);
|
||||
if (init != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
@@ -115,7 +129,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
|
||||
// hWnd=0: wnwrap supports a pull-based model — no message pump
|
||||
// is required. We poll GetXmlCurrentAlarms2 on a timer below.
|
||||
int reg = com.RegisterConsumer(
|
||||
int reg = com.IwwAlarmConsumer_RegisterConsumer(
|
||||
hWnd: 0,
|
||||
szProductName: DefaultProductName,
|
||||
szApplicationName: DefaultApplicationName,
|
||||
@@ -126,7 +140,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
$"wwAlarmConsumer.RegisterConsumer returned non-zero status {reg}.");
|
||||
}
|
||||
|
||||
int sub = com.Subscribe(
|
||||
int sub = com.IwwAlarmConsumer_Subscribe(
|
||||
szSubscription: subscription,
|
||||
wFromPri: 1,
|
||||
wToPri: 999,
|
||||
@@ -140,8 +154,49 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
$"wwAlarmConsumer.Subscribe('{subscription}') returned non-zero status {sub}.");
|
||||
}
|
||||
|
||||
// Empirically required: even though the round-trip echo of
|
||||
// SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md),
|
||||
// calling it is necessary for subsequent GetXmlCurrentAlarms2
|
||||
// calls to succeed. Without it, GetXmlCurrentAlarms2 returns
|
||||
// E_FAIL (HRESULT 0x80004005) on the first poll. SetXmlAlarmQuery
|
||||
// also breaks AlarmAckByName on the same consumer (rejects with
|
||||
// -55), so a separate ack-only consumer is provisioned below
|
||||
// that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery).
|
||||
string xmlQuery = ComposeXmlAlarmQuery(subscription);
|
||||
com.SetXmlAlarmQuery(xmlQuery);
|
||||
|
||||
// Provision a parallel COM consumer for ack calls. It runs the
|
||||
// v1 lifecycle (Initialize/Register/Subscribe) only; without
|
||||
// SetXmlAlarmQuery, AlarmAckByName succeeds. State is read-only
|
||||
// — we never poll this consumer.
|
||||
ackClient = new wwAlarmConsumerClass();
|
||||
int ackInit = ackClient.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName + ".ack");
|
||||
int ackReg = ackClient.IwwAlarmConsumer_RegisterConsumer(
|
||||
hWnd: 0,
|
||||
szProductName: DefaultProductName,
|
||||
szApplicationName: DefaultApplicationName + ".ack",
|
||||
szVersion: DefaultVersion);
|
||||
int ackSub = ackClient.IwwAlarmConsumer_Subscribe(
|
||||
szSubscription: subscription,
|
||||
wFromPri: 1,
|
||||
wToPri: 999,
|
||||
QueryType: eQueryType.qtSummary,
|
||||
SortFlags: eSortFlags.sfReturnNewestFirst,
|
||||
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
||||
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
||||
if (ackInit != 0 || ackReg != 0 || ackSub != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Ack consumer setup returned non-zero status: " +
|
||||
$"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}.");
|
||||
}
|
||||
subscriptionExpression = subscription;
|
||||
|
||||
subscribed = true;
|
||||
pollTimer = new Timer(OnPoll, state: null, dueTime: 0, period: pollIntervalMs);
|
||||
if (pollIntervalMs > 0)
|
||||
{
|
||||
pollTimer = new Timer(OnPoll, state: null, dueTime: 0, period: pollIntervalMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,18 +240,31 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
|
||||
wwAlarmConsumerClass com = client
|
||||
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
// Use the parallel ack-only consumer (no SetXmlAlarmQuery applied)
|
||||
// — see docs/AlarmClientDiscovery.md "Option A — captured" for the
|
||||
// empirical justification.
|
||||
wwAlarmConsumerClass com = ackClient
|
||||
?? throw new InvalidOperationException(
|
||||
"Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet.");
|
||||
|
||||
// Empirically (live dev-rig 2026-05-01): the IwwAlarmConsumer2
|
||||
// 8-arg AlarmAckByName returns -55 on this AVEVA build (looks like
|
||||
// a stub). The legacy 6-arg IwwAlarmConsumer.AlarmAckByName works
|
||||
// and reaches the alarm-history path correctly. Operator-domain
|
||||
// and operator-full-name fields are accepted by the proto contract
|
||||
// for forward-compat but are not propagated to AVEVA today —
|
||||
// wrapped in the 6-arg call so domain/full-name go to the
|
||||
// alarm-history operator-name field via the szOprName parameter.
|
||||
// Suppress unused-warning explicitly:
|
||||
_ = ackOperatorDomain;
|
||||
_ = ackOperatorFullName;
|
||||
return com.AlarmAckByName(
|
||||
szAlarmName: alarmName ?? string.Empty,
|
||||
szProviderName: providerName ?? string.Empty,
|
||||
szGroupName: groupName ?? string.Empty,
|
||||
szComment: ackComment ?? string.Empty,
|
||||
szOprName: ackOperatorName ?? string.Empty,
|
||||
szNode: ackOperatorNode ?? string.Empty,
|
||||
szDomainName: ackOperatorDomain ?? string.Empty,
|
||||
szOprFullName: ackOperatorFullName ?? string.Empty);
|
||||
szNode: ackOperatorNode ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -236,7 +304,15 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
}
|
||||
}
|
||||
|
||||
internal void PollOnce()
|
||||
/// <summary>
|
||||
/// Synchronously poll the wnwrap consumer once and dispatch any
|
||||
/// transitions. Public so STA-bound hosts can drive polling from
|
||||
/// the thread that owns the COM object instead of relying on the
|
||||
/// internal <see cref="Timer"/> (which fires on a thread-pool
|
||||
/// thread and blocks indefinitely on cross-apartment marshaling
|
||||
/// when the host STA isn't pumping messages).
|
||||
/// </summary>
|
||||
public void PollOnce()
|
||||
{
|
||||
wwAlarmConsumerClass? com;
|
||||
lock (syncRoot)
|
||||
@@ -370,6 +446,58 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
return Guid.TryParse(canonical, out guid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose the XML payload <c>SetXmlAlarmQuery</c> expects from a
|
||||
/// canonical subscription expression
|
||||
/// (<c>\\<machine>\Galaxy!<area></c>). The wnwrap
|
||||
/// consumer mangles the round-trip but evidently still needs the
|
||||
/// call — without it <c>GetXmlCurrentAlarms2</c> fails with
|
||||
/// E_FAIL. Best-effort parse: if the subscription doesn't decompose
|
||||
/// cleanly, fall back to a permissive ALL-priority/ALL-state form
|
||||
/// so the worker doesn't fail to start.
|
||||
/// </summary>
|
||||
internal static string ComposeXmlAlarmQuery(string subscription)
|
||||
{
|
||||
string node = Environment.MachineName;
|
||||
string provider = "Galaxy";
|
||||
string group = string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(subscription))
|
||||
{
|
||||
// Strip leading backslashes from "\\<node>\..." form.
|
||||
string trimmed = subscription.TrimStart('\\');
|
||||
int slash = trimmed.IndexOf('\\');
|
||||
if (slash > 0)
|
||||
{
|
||||
node = trimmed.Substring(0, slash);
|
||||
trimmed = trimmed.Substring(slash + 1);
|
||||
}
|
||||
int bang = trimmed.IndexOf('!');
|
||||
if (bang > 0)
|
||||
{
|
||||
provider = trimmed.Substring(0, bang);
|
||||
group = trimmed.Substring(bang + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
provider = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
System.Text.StringBuilder sb = new System.Text.StringBuilder();
|
||||
sb.Append("<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">");
|
||||
sb.Append("<QUERY>");
|
||||
sb.Append("<NODE>").Append(node).Append("</NODE>");
|
||||
sb.Append("<PROVIDER>").Append(provider).Append("</PROVIDER>");
|
||||
if (!string.IsNullOrEmpty(group))
|
||||
{
|
||||
sb.Append("<GROUP>").Append(group).Append("</GROUP>");
|
||||
}
|
||||
sb.Append("</QUERY>");
|
||||
sb.Append("</QUERIES>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static VBGUID ToVbGuid(Guid g)
|
||||
{
|
||||
byte[] bytes = g.ToByteArray();
|
||||
@@ -390,6 +518,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
{
|
||||
Timer? timerToDispose;
|
||||
wwAlarmConsumerClass? clientToDispose;
|
||||
wwAlarmConsumerClass? ackClientToDispose;
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (disposed) return;
|
||||
@@ -398,16 +527,22 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
pollTimer = null;
|
||||
clientToDispose = client;
|
||||
client = null;
|
||||
ackClientToDispose = ackClient;
|
||||
ackClient = null;
|
||||
}
|
||||
timerToDispose?.Dispose();
|
||||
if (clientToDispose is not null)
|
||||
ReleaseConsumerCom(clientToDispose);
|
||||
ReleaseConsumerCom(ackClientToDispose);
|
||||
}
|
||||
|
||||
private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer)
|
||||
{
|
||||
if (consumer is null) return;
|
||||
try { consumer.DeregisterConsumer(); } catch { /* swallow */ }
|
||||
try { consumer.UninitializeConsumer(); } catch { /* swallow */ }
|
||||
if (Marshal.IsComObject(consumer))
|
||||
{
|
||||
try { clientToDispose.DeregisterConsumer(); } catch { /* swallow */ }
|
||||
try { clientToDispose.UninitializeConsumer(); } catch { /* swallow */ }
|
||||
if (Marshal.IsComObject(clientToDispose))
|
||||
{
|
||||
try { Marshal.FinalReleaseComObject(clientToDispose); } catch { /* swallow */ }
|
||||
}
|
||||
try { Marshal.FinalReleaseComObject(consumer); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user