Compare commits

...

5 Commits

Author SHA1 Message Date
Joseph Doherty 8c095b9a56 Record the managed GUID alarm-ack live result
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m25s
ci / java (push) Successful in 2m18s
ci / portable (push) Failing after 2m39s
The 2026-10-18 live test on the dev rig showed the opt-in managed ack path
cannot work as built: AlarmClient.RegisterConsumer returns -65 alongside the
worker's wnwrap reader (the worker then silently falls back to wnwrap), the
managed client does not recognise the wnwrap GUID even standalone, and the
gateway's GUID arm sends empty identity fields. Replace the docs' claim that
the path works with the measured results and tell operators to leave
MXGATEWAY_USE_MANAGED_ALARM_ACK unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:11:11 -04:00
Joseph Doherty 6c82de55c6 Merge pull request 'Add opt-in managed GUID alarm-ack path' (#160) from feat/managed-guid-alarm-ack into main
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m59s
ci / portable (push) Failing after 2m21s
ci / java (push) Successful in 2m7s
2026-10-18 13:12:12 -04:00
Joseph Doherty 6f4c7c3bf1 Keep subtag acks and alarm reads safe on the managed ack path
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (pull_request) Successful in 2m0s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m16s
ci / java (push) Successful in 2m19s
ci / portable (push) Failing after 2m31s
ci / portable (pull_request) Failing after 2m36s
ci / windows-x86 (push) Successful in 3m11s
Review fixes for the opt-in managed GUID ack:

- Subtag-mode GUID acks (forced subtag, or failover on the standby) stay on
  the consumer; their GUIDs are synthetic and only the subtag consumer can
  resolve them. Forced-subtag sessions no longer build a managed client.
- A failed managed Initialize/Register/Subscribe drops back to the wnwrap
  ack path instead of failing the alarm subscription.
- Drop stale crefs to a nonexistent Unverified member in
  WnalNativeAckConsumer.
- Unit tests for routing, fallback, and disposal; document
  MXGATEWAY_USE_MANAGED_ALARM_ACK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:11:01 -04:00
Joseph Doherty 6affbd57ea feat(worker): add opt-in managed GUID alarm-ack path
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m19s
ci / java (push) Successful in 2m8s
ci / portable (push) Failing after 2m23s
ci / nightly-windev (pull_request) Has been skipped
ci / windows-x86 (pull_request) Successful in 1m18s
ci / java (pull_request) Successful in 2m3s
ci / portable (pull_request) Failing after 2m17s
Adds a managed acknowledge path over AVEVA's modern alarm client so GUID
acks carry full operator identity (domain + full name) and work on builds
where the wnwrap COM AlarmAckByGUID is E_NOTIMPL. Default off; opt in with
MXGATEWAY_USE_MANAGED_ALARM_ACK=1 (wnwrap behavior unchanged otherwise).

- AaManagedAlarmAckConsumer: reflection wrapper over
  aaAlarmManagedClient.AlarmClient (InitializeConsumer/RegisterConsumer/
  Subscribe/AlarmAckByGUID); EnvGatedFactory degrades to null (wnwrap) if the
  DLL can't load, so alarm reads never break.
- IManagedAlarmAckClient seam. AlarmDispatcher takes an optional client and
  routes GUID acks to it (subscribing + disposing it alongside the reader).
- AlarmCommandHandler gains an optional managedAckFactory; WorkerPipeSession
  wires AaManagedAlarmAckConsumer.EnvGatedFactory at the production sites.
- WnalNativeAckConsumer: confirmed native alarmlst.dll WNAL submit layer
  (WNALalarmReqSetText / WNALUserRequest / WNALUserRequestEx + lpAlmAck
  layout). List acquisition is intentionally not hand-rolled: the alarm-record
  layout and selection semantics are provider-defined and not recoverable from
  alarmlst.dll, so inferring them risks acking the wrong alarm.
- AlarmManagedGuidAckLiveSmokeTests: skip-gated live smoke that pulls a real
  (non-degraded) alarm GUID and asserts the ack APPLIES (Acknowledge
  transition / acked snapshot), not merely rc=0.

By-name acks stay on wnwrap (AlarmClient has no AlarmAckByName). Grounded in
static RE of alarmlst.dll, aaAlarmManagedClient.dll and alarmmgr.exe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 12:42:07 -04:00
dohertj2 6fc423f608 Merge pull request 'Retain fake worker fault diagnostics in test fixtures' (#155) from fix/fake-worker-fault-diagnostics into main
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m17s
ci / java (push) Successful in 2m3s
ci / portable (push) Successful in 8m23s
2026-10-13 10:46:14 -04:00
10 changed files with 1054 additions and 6 deletions
+49
View File
@@ -793,6 +793,55 @@ in the codebase for the forward-compat shape, but the gateway-side
`AcknowledgeAlarmByName` when the public RPC supplies a recognizable
`Provider!Group.Tag` reference.
**Opt-in managed GUID ack — does not work on this rig (live-tested
2026-10-18).** Setting `MXGATEWAY_USE_MANAGED_ALARM_ACK=1` in the
gateway's environment (workers inherit it) makes the worker build an
`AaManagedAlarmAckConsumer`, which drives
`aaAlarmManagedClient.AlarmClient` by reflection (its members are
non-public) and is meant to take GUID-keyed acks in place of the
wnwrap method. The live test shows it cannot do that as built, so leave
the flag off.
The test raised `TestMachine_001.TestAlarm001` with a secured write,
read its GUID from the wnwrap XML snapshot (the public API exposes no
alarm GUIDs), and acknowledged it through the `AcknowledgeAlarm` RPC
with the flag on:
| Step | Result |
|---|---|
| RPC ack by GUID, flag off (control) | `NotImplementedException`; `.Acked` stays `False` |
| RPC ack by GUID, flag on | Same `NotImplementedException`; `.Acked` `False`, `.AckMsg` empty |
| `AlarmClient.RegisterConsumer` in a process with a registered wnwrap consumer | `-65` — and wnwrap gets `-65` if the managed client registered first; a real `hWnd` does not help |
| Managed client alone in its own process, records loaded | `AlarmAckByGUID` → `-1`, `SelectByGUID` → `-1` for the wnwrap GUID in both byte orders, `AlarmAckSelected` → `-57` |
Three separate problems follow:
1. **The two libraries cannot share a process.** The worker always runs
a wnwrap reader, so the managed `RegisterConsumer` fails with `-65`.
The dispatcher is built to drop back to the wnwrap ack path on a
lifecycle failure (so alarm reads keep working), and it does so
silently — which is why the flag-on RPC is indistinguishable from the
control.
2. **The managed client does not recognise the wnwrap GUID.** `-1` from
`SelectByGUID` is "not found", not a security refusal, even with the
client holding records. Getting the client's own identifier for the
alarm is not reliable either: `GetHighPriAlarm` mostly throws the
FILETIME `ArgumentOutOfRangeException` described above. The
`MxSecurityOperate` question was therefore never reached.
3. **The gateway does not send the identity fields.** Its GUID arm fills
`OperatorNode`, `OperatorDomain` and `OperatorFullName` with empty
strings, so the domain and full name the managed method carries
would not arrive through the public RPC even if 1 and 2 were solved.
A working managed ack would need the client hosted out of process, a way
to resolve the alarm to the identifier the managed client uses, and the
identity fields plumbed through the gateway. Routing rules the worker
already applies stay correct and would carry over: subtag-mode acks
(synthetic GUIDs) stay on the subtag consumer, and by-name acks stay on
wnwrap because `AlarmClient` has no by-name ack. The skip-gated
`AlarmManagedGuidAckLiveSmokeTests` exercises the in-process
arrangement and hits problem 1.
### 5. STA / threading — resolved
The wnwrap COM is `ThreadingModel=Apartment`. The consumer's
+8
View File
@@ -446,6 +446,14 @@ behavior.
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
`StreamAlarms` are session-less RPCs served by the monitor.
The worker also reads `MXGATEWAY_USE_MANAGED_ALARM_ACK` (inherited from the
gateway process). Setting it to `1` is meant to route GUID-keyed acknowledges
through AVEVA's managed alarm client, but a live test (2026-10-18) showed the
path cannot work as built: the managed client fails to register alongside the
worker's wnwrap reader and the worker silently falls back to wnwrap. Leave it
unset. It has no `MxGateway:*` option. See
[AlarmClientDiscovery](./AlarmClientDiscovery.md) ("Opt-in managed GUID ack").
### Alarm fallback options
The `Fallback` sub-section controls how the alarm feed selects between the
@@ -203,6 +203,100 @@ public sealed class AlarmDispatcherTests
Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName);
}
/// <summary>Verifies that a managed ack client takes GUID acks with full operator identity.</summary>
[Fact]
public void Acknowledge_WithManagedAckClient_RoutesGuidAckToManagedClient()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = -1 };
FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = 0 };
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
managed);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
Assert.Equal(0, rc);
Assert.Equal(@"\\HOST\Galaxy!Area1", managed.LastSubscription);
Assert.Equal(guid, managed.LastAckGuid);
Assert.Equal("CORP", managed.LastAckOperatorDomain);
Assert.Equal("Alice Smith", managed.LastAckOperatorFullName);
Assert.Equal(Guid.Empty, consumer.LastAckGuid);
}
/// <summary>
/// Verifies that a failed managed-client lifecycle neither fails the alarm
/// subscription nor leaves acks routed to the broken client.
/// </summary>
[Fact]
public void Subscribe_WhenManagedAckClientFails_FallsBackToConsumerAck()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
FakeManagedAckClient managed = new FakeManagedAckClient { ThrowOnSubscribe = true };
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
managed);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
Assert.True(managed.Disposed);
Assert.Equal(0, rc);
Assert.Equal(guid, consumer.LastAckGuid);
Assert.Equal(Guid.Empty, managed.LastAckGuid);
}
/// <summary>
/// Verifies that while the failover consumer serves the subtag standby,
/// GUID acks (synthetic GUIDs) stay on the consumer, not the managed client.
/// </summary>
[Fact]
public void Acknowledge_WhenFailoverInSubtagMode_KeepsGuidAckOnConsumer()
{
FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true };
FakeAlarmConsumer standby = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
FailoverAlarmConsumer failover = new FailoverAlarmConsumer(
primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3));
FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = -1 };
using AlarmDispatcher dispatcher = new AlarmDispatcher(
failover,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
managed);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Assert.Equal(AlarmProviderMode.Subtag, failover.Mode);
Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
Assert.Equal(0, rc);
Assert.Equal(guid, standby.LastAckGuid);
Assert.Equal(Guid.Empty, managed.LastAckGuid);
}
/// <summary>Verifies that disposing the dispatcher disposes the managed ack client.</summary>
[Fact]
public void Dispose_WithManagedAckClient_DisposesManagedClient()
{
FakeManagedAckClient managed = new FakeManagedAckClient();
AlarmDispatcher dispatcher = new AlarmDispatcher(
new FakeAlarmConsumer(),
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
managed);
dispatcher.Dispose();
Assert.True(managed.Disposed);
}
/// <summary>Verifies that acknowledge-by-name calls are forwarded to the consumer.</summary>
[Fact]
public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple()
@@ -470,9 +564,13 @@ public sealed class AlarmDispatcherTests
AlarmTransitionEmitted?.Invoke(this, transition);
}
/// <summary>Gets or sets a value indicating whether <see cref="Subscribe"/> throws.</summary>
public bool ThrowOnSubscribe { get; set; }
/// <inheritdoc />
public void Subscribe(string subscription)
{
if (ThrowOnSubscribe) throw new InvalidOperationException("subscribe failed");
LastSubscription = subscription;
}
@@ -533,4 +631,50 @@ public sealed class AlarmDispatcherTests
Disposed = true;
}
}
private sealed class FakeManagedAckClient : IManagedAlarmAckClient
{
/// <summary>Gets the last subscription expression.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Gets the GUID from the last acknowledge call.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
public string? LastAckOperatorDomain { get; private set; }
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
public string? LastAckOperatorFullName { get; private set; }
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets a value indicating whether <see cref="Subscribe"/> throws.</summary>
public bool ThrowOnSubscribe { get; set; }
/// <summary>Gets a value indicating whether this instance has been disposed.</summary>
public bool Disposed { get; private set; }
/// <inheritdoc />
public void Subscribe(string subscription)
{
if (ThrowOnSubscribe) throw new InvalidOperationException("RegisterConsumer returned 5.");
LastSubscription = subscription;
}
/// <inheritdoc />
public int AcknowledgeByGuid(
Guid alarmGuid,
string comment,
string operatorName,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
LastAckGuid = alarmGuid;
LastAckOperatorDomain = operatorDomain;
LastAckOperatorFullName = operatorFullName;
return AcknowledgeReturn;
}
/// <inheritdoc />
public void Dispose()
{
Disposed = true;
}
}
}
@@ -0,0 +1,229 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
using Xunit;
using Xunit.Abstractions;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
/// <summary>
/// Live dev-rig smoke test for the MANAGED GUID-ack path — the
/// <see cref="AaManagedAlarmAckConsumer"/> wired into
/// <see cref="AlarmDispatcher"/>. Where <c>AlarmsLiveSmokeTests</c> acks by
/// name through the wnwrap COM consumer, this acks by GUID through AVEVA's
/// modern <c>aaAlarmManagedClient.AlarmClient.AlarmAckByGUID</c> — the path
/// that carries operator domain + full name and is not <c>E_NOTIMPL</c>.
///
/// Flow: subscribe a wnwrap reader + a managed ack client to
/// <c>\\&lt;machine&gt;\Galaxy!DEV</c>; wait for a Raise; pull a real
/// (non-degraded) alarm GUID from the reader's snapshot; ack it by GUID via
/// the dispatcher (which routes to the managed client); then assert the ack
/// registers as a subsequent <see cref="AlarmTransitionKind.Acknowledge"/>
/// transition (and that the active snapshot shows the alarm acked).
///
/// This is the runtime confirmation the static work could not close: that an
/// authenticated GUID ack actually flips state. rc=0 alone is "accepted",
/// not "applied" — the Acknowledge transition is the real assertion. For a
/// SECURED alarm the ack must run under an operator with Operate permission;
/// run the dev-rig flip script and, if the test UDA is secured, an
/// authenticated operator session (see docs/AlarmProbeFindings.md).
///
/// Skip-gated; flip <c>Skip=null</c> on the dev rig with AVEVA +
/// aaAlarmManagedClient.dll present and the flip script running.
/// </summary>
public sealed class AlarmManagedGuidAckLiveSmokeTests
{
private static readonly string SubscriptionExpression =
$@"\\{Environment.MachineName}\Galaxy!DEV";
private static readonly TimeSpan TransitionWaitTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan RaiseWait = TimeSpan.FromSeconds(30);
private static readonly TimeSpan AckConfirmWait = TimeSpan.FromSeconds(30);
private const string SessionId = "alarms-managed-guid-ack-smoke";
private readonly ITestOutputHelper output;
private readonly Stopwatch elapsed = Stopwatch.StartNew();
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
/// <summary>Initializes a new instance of the AlarmManagedGuidAckLiveSmokeTests class.</summary>
/// <param name="output">Test output helper for logging.</param>
public AlarmManagedGuidAckLiveSmokeTests(ITestOutputHelper output)
{
this.output = output;
}
/// <summary>Acks a live alarm by GUID through the managed client and verifies the acked transition.</summary>
[Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + aaAlarmManagedClient.dll + the alarm flip script, and an operator authorized to ack.")]
public void ManagedGuidAck_AcksActiveAlarm_RegistersAcknowledge()
{
Exception? threadException = null;
var done = new ManualResetEventSlim(false);
var thread = new Thread(() =>
{
try { RunSmoke(); }
catch (Exception ex) { threadException = ex; }
finally { done.Set(); }
});
thread.IsBackground = false;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
done.Wait();
thread.Join();
output.WriteLine($"Captured {log.Count} log line(s):");
while (log.TryDequeue(out string? line)) output.WriteLine(line);
if (threadException != null) throw threadException;
}
private void RunSmoke()
{
Log($"Subscription expression: {SubscriptionExpression}");
MxAccessEventQueue queue = new MxAccessEventQueue();
WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(
new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
maxAlarmsPerFetch: 1024);
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
// Construct the managed ack client directly (production selects it via
// AaManagedAlarmAckConsumer.EnvGatedFactory when MXGATEWAY_USE_MANAGED_ALARM_ACK=1).
using IManagedAlarmAckClient managedAck =
new AaManagedAlarmAckConsumer(SessionId + ".ack");
using AlarmDispatcher dispatcher =
new AlarmDispatcher(consumer, sink, SessionId, managedAck);
Log("Constructed reader + managed ack client + dispatcher.");
dispatcher.Subscribe(SubscriptionExpression);
Log("Subscribe (reader + managed ack) -> ok. Driving PollOnce from this STA...");
// 1. Wait for a Raise so there is an active, unacked alarm to ack.
OnAlarmTransitionEvent? raiseBody = null;
DateTime raiseDeadline = DateTime.UtcNow + RaiseWait;
while (DateTime.UtcNow < raiseDeadline && raiseBody is null)
{
WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "raise", consumer);
if (evt is null) break;
OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
Log("Transition: " + DescribeTransition(body));
Assert.Equal(SessionId, evt.Event.SessionId);
if (body.TransitionKind == AlarmTransitionKind.Raise) raiseBody = body;
}
Assert.NotNull(raiseBody);
// 2. Pull a REAL alarm GUID from the reader's snapshot (alarmmgr mode).
// Skip degraded/subtag records — their GUID is synthetic and not
// valid for AlarmAckByGUID.
var records = consumer.SnapshotActiveAlarms(out _);
Log($"Snapshot records: {records.Count}");
foreach (var r in records)
Log($" rec guid={r.AlarmGuid} state={r.State} degraded={r.Degraded} ref={r.ProviderName}!{r.Group}.{r.TagName}");
MxAlarmSnapshotRecord? target = records.FirstOrDefault(r =>
!r.Degraded &&
r.AlarmGuid != Guid.Empty &&
r.State == MxAlarmStateKind.UnackAlm);
Assert.NotNull(target);
Log($"Ack target GUID={target!.AlarmGuid} ref={target.ProviderName}!{target.Group}.{target.TagName}");
// 3. Ack by GUID through the dispatcher -> managed AlarmAckByGUID, with
// the current Windows identity (domain + full name are carried
// natively on this path, unlike the wnwrap 6-arg AckByName).
string user = Environment.UserName;
string node = Environment.MachineName;
string domain = Environment.UserDomainName ?? string.Empty;
Log($"Ack identity user='{user}' node='{node}' domain='{domain}'");
int rc = dispatcher.Acknowledge(
alarmGuid: target.AlarmGuid,
ackComment: "managed-guid-ack smoke",
ackOperatorName: user,
ackOperatorNode: node,
ackOperatorDomain: domain,
ackOperatorFullName: user);
Log($"dispatcher.Acknowledge(managed GUID) -> rc={rc}");
Assert.Equal(0, rc);
// 4. The ack must APPLY, not merely be accepted: wait for an
// Acknowledge transition on the same alarm, or the snapshot showing
// it acked. rc=0 with neither is the inert-ack failure the probe saw.
bool acked = false;
DateTime confirmDeadline = DateTime.UtcNow + AckConfirmWait;
while (DateTime.UtcNow < confirmDeadline && !acked)
{
WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "post-ack", consumer);
if (evt is not null)
{
OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
Log("Post-ack transition: " + DescribeTransition(body));
if (body.TransitionKind == AlarmTransitionKind.Acknowledge)
{
acked = true;
break;
}
}
// Also accept the active snapshot flipping to an acked state for the
// target GUID, in case the ack delta was folded without a discrete
// Acknowledge transition.
var now = consumer.SnapshotActiveAlarms(out _);
if (now.Any(r => r.AlarmGuid == target.AlarmGuid &&
(r.State == MxAlarmStateKind.AckAlm || !string.IsNullOrEmpty(r.OperatorName))))
{
Log("Snapshot shows target GUID acked (AckAlm / operator populated).");
acked = true;
}
}
Assert.True(
acked,
"Managed GUID ack returned rc=0 but no Acknowledge transition or acked snapshot was observed " +
"within the confirm window — the ack was accepted but not applied (see docs/AlarmProbeFindings.md; " +
"most likely the alarm's MxSecurityOperate classification requires an authenticated operator).");
}
private WorkerEvent? WaitForTransition(
MxAccessEventQueue queue,
TimeSpan timeout,
string label,
WnWrapAlarmConsumer consumer)
{
DateTime deadline = DateTime.UtcNow + timeout;
int pollCount = 0;
while (DateTime.UtcNow < deadline)
{
try
{
consumer.PollOnce();
pollCount++;
}
catch (Exception ex)
{
Log($"PollOnce threw on poll #{pollCount + 1}: {ex.GetType().Name}: {ex.Message}");
throw;
}
if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null)
{
if (evt.Event.Family == MxEventFamily.OnAlarmTransition) return evt;
Log($"Skipped non-alarm event (family={evt.Event.Family}) while waiting for {label}.");
}
Thread.Sleep(500);
}
Log($"Timed out waiting for {label} transition after {timeout.TotalSeconds:F0}s (polls={pollCount}).");
return null;
}
private static string DescribeTransition(OnAlarmTransitionEvent body)
{
return string.Format(
"kind={0} ref='{1}' operator='{2}' comment='{3}' ts={4:o}",
body.TransitionKind, body.AlarmFullReference, body.OperatorUser, body.OperatorComment,
body.TransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
}
private void Log(string line) => log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
}
@@ -76,7 +76,7 @@ public sealed class WorkerPipeSession
options,
() => Process.GetCurrentProcess().Id,
new WorkerPipeSessionOptions(),
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)),
logger,
stream)
{
@@ -98,7 +98,7 @@ public sealed class WorkerPipeSession
options,
processIdProvider,
new WorkerPipeSessionOptions(),
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)),
logger: null)
{
}
@@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession
// parameterless CompleteStartupHandshakeAsync is used without a
// prior factory call.
_runtimeSession ??= new MxAccessStaSession(
(eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null));
(eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory));
IWorkerRuntimeSession session = _runtimeSession;
try
{
@@ -0,0 +1,231 @@
using System;
using System.IO;
using System.Reflection;
using WNWRAPCONSUMERLib;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Acknowledge path over AVEVA's modern managed alarm client,
/// <c>aaAlarmManagedClient.AlarmClient</c> (the C++/CLI assembly the AVEVA
/// Alarm Control itself uses: <c>AlarmClient → CwwAlarmConsumer →
/// alarmlst.dll → alarmmgr.exe</c>). Preferred over a hand-rolled native
/// WNAL consumer because <c>AlarmAckByGUID</c> performs the
/// register → match-by-GUID → submit sequence internally — the record
/// layout and selection semantics that are provider-defined (and therefore
/// NOT safely recoverable by static RE of alarmlst.dll) stay inside AVEVA's
/// own code. See <see cref="WnalNativeAckConsumer"/> for why the native
/// acquisition path is deliberately unfinished.
///
/// The <c>AlarmClient</c> members are <c>famorasm</c> (protected-internal)
/// and mxaccessgw is not an <c>InternalsVisibleTo</c> friend, so everything
/// here is invoked by reflection. In full-trust .NET Framework 4.8 that is
/// permitted. Method shapes are confirmed from the assembly metadata:
/// .ctor() (internal)
/// int InitializeConsumer(string appName)
/// int RegisterConsumer(int hWnd, string product, string app, string version, bool)
/// int Subscribe(string expr, short fromPri, short toPri,
/// localQueryType, localSortFlags,
/// localAlarmFilterState mask, localAlarmFilterState spec)
/// int AlarmAckByGUID(System.Guid guid, string comment, string oper,
/// string node, string domain, string fullName)
/// int DeregisterConsumer() / int UninitializeConsumer() / Dispose()
///
/// THREADING: the underlying consumer is apartment-affine (same as the
/// wnwrap COM path). Construct and call this only on the worker STA
/// (marshal via <c>StaRuntime.InvokeAsync</c>).
///
/// x86 ONLY — aaAlarmManagedClient.dll is a 32-bit mixed-mode PE.
///
/// OPEN (confirm at runtime, flagged inline): the trailing <c>bool</c> of
/// <c>RegisterConsumer</c> (passed <c>false</c> here); whether
/// <c>Subscribe</c> is actually required before an ack by GUID; and the
/// native status returned on a real secured ack.
/// </summary>
public sealed class AaManagedAlarmAckConsumer : IManagedAlarmAckClient
{
/// <summary>Default install location of the managed alarm client (eac.dll folder).</summary>
public const string DefaultAssemblyPath =
@"C:\Program Files (x86)\ArchestrA\Framework\Bin\Invensys_Systems__Inc__eac.dll\aaAlarmManagedClient.dll";
/// <summary>
/// Env var that opts the worker into the managed ack path. Unset/anything
/// other than "1" keeps the existing wnwrap ack behavior.
/// </summary>
public const string EnableEnvVar = "MXGATEWAY_USE_MANAGED_ALARM_ACK";
private const string AckApplicationName = "ZB.MOM.WW.MxGateway.ack";
/// <summary>
/// Production factory for the <see cref="AlarmCommandHandler"/> seam:
/// returns a managed ack client only when <see cref="EnableEnvVar"/> == "1",
/// otherwise <see langword="null"/> (ack stays on the wnwrap path). Never
/// throws — a construction failure (missing DLL, etc.) degrades to the
/// wnwrap path rather than breaking the alarm subscription.
/// </summary>
public static IManagedAlarmAckClient? EnvGatedFactory()
{
if (!string.Equals(Environment.GetEnvironmentVariable(EnableEnvVar), "1", StringComparison.Ordinal))
return null;
try
{
return new AaManagedAlarmAckConsumer(AckApplicationName);
}
catch
{
// Opt-in enhancement only: if the managed client can't load, fall
// back to the wnwrap ack path rather than failing alarm reads.
return null;
}
}
private const string TypeName = "aaAlarmManagedClient.AlarmClient";
private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
private readonly object syncRoot = new();
private readonly object client;
private readonly MethodInfo initializeConsumer;
private readonly MethodInfo registerConsumer;
private readonly MethodInfo subscribe;
private readonly MethodInfo alarmAckByGuid;
private readonly MethodInfo? deregisterConsumer;
private readonly MethodInfo? uninitializeConsumer;
private bool subscribed;
private bool disposed;
/// <param name="applicationName">Consumer application name (e.g. "MxGateway.ack").</param>
/// <param name="assemblyPath">Override for <see cref="DefaultAssemblyPath"/>.</param>
public AaManagedAlarmAckConsumer(string applicationName, string? assemblyPath = null)
{
if (string.IsNullOrWhiteSpace(applicationName))
throw new ArgumentException("Application name is required.", nameof(applicationName));
string path = assemblyPath ?? DefaultAssemblyPath;
if (!File.Exists(path))
throw new FileNotFoundException($"aaAlarmManagedClient.dll not found at '{path}'.", path);
Assembly asm = Assembly.LoadFrom(path);
Type type = asm.GetType(TypeName, throwOnError: true)
?? throw new InvalidOperationException($"Type '{TypeName}' not found in {path}.");
initializeConsumer = Resolve(type, "InitializeConsumer");
registerConsumer = Resolve(type, "RegisterConsumer");
subscribe = Resolve(type, "Subscribe");
alarmAckByGuid = Resolve(type, "AlarmAckByGUID");
deregisterConsumer = type.GetMethod("DeregisterConsumer", Inst, null, Type.EmptyTypes, null);
uninitializeConsumer = type.GetMethod("UninitializeConsumer", Inst, null, Type.EmptyTypes, null);
client = Activator.CreateInstance(type, nonPublic: true)
?? throw new InvalidOperationException($"Could not construct '{TypeName}'.");
ApplicationName = applicationName;
}
/// <summary>The consumer application name this instance registered under.</summary>
public string ApplicationName { get; }
private static MethodInfo Resolve(Type type, string name) =>
type.GetMethod(name, Inst)
?? throw new MissingMethodException(TypeName, name);
/// <summary>
/// Run the Initialize → Register → Subscribe lifecycle once, mirroring the
/// proven wnwrap argument values. <paramref name="subscription"/> is the
/// alarm query root, e.g. <c>\\NODE\Galaxy!Area</c>. Throws if any native
/// status is non-zero.
/// </summary>
public void Subscribe(string subscription)
{
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
lock (syncRoot)
{
if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer));
if (subscribed) throw new InvalidOperationException("Subscribe was already called.");
int init = (int)initializeConsumer.Invoke(client, new object[] { ApplicationName })!;
if (init != 0) throw new InvalidOperationException($"InitializeConsumer returned {init}.");
// RegisterConsumer(hWnd, product, app, version, bool). hWnd=0 => pull model.
// The trailing bool is unconfirmed; false = client/non-modal is the safe default.
int reg = (int)registerConsumer.Invoke(client, new object[]
{
0, "ZB.MOM.WW.MxGateway", ApplicationName, "1.0", false,
})!;
if (reg != 0) throw new InvalidOperationException($"RegisterConsumer returned {reg}.");
int sub = (int)subscribe.Invoke(client, BuildSubscribeArgs(subscription))!;
if (sub != 0) throw new InvalidOperationException($"Subscribe('{subscription}') returned {sub}.");
subscribed = true;
}
}
/// <summary>
/// Acknowledge a single alarm by GUID with full operator identity. Returns
/// AVEVA's native status (0 = success). A zero return means accepted, not
/// necessarily applied — verify the alarm's acked state / Historian row
/// (see docs/AlarmProbeFindings.md).
/// </summary>
public int AcknowledgeByGuid(
Guid alarmGuid,
string comment,
string operatorName,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
lock (syncRoot)
{
if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer));
if (!subscribed) throw new InvalidOperationException("Subscribe must be called before acknowledging.");
return (int)alarmAckByGuid.Invoke(client, new object[]
{
alarmGuid,
comment ?? string.Empty,
operatorName ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty,
})!;
}
}
/// <summary>
/// Build the 7 Subscribe args, boxing the proven wnwrap enum integers into
/// aaAlarmManagedClient's own enum parameter types (localQueryType /
/// localSortFlags / localAlarmFilterState) via <see cref="Enum.ToObject(Type,object)"/>.
/// </summary>
private object[] BuildSubscribeArgs(string subscription)
{
ParameterInfo[] p = subscribe.GetParameters();
if (p.Length != 7)
throw new InvalidOperationException($"Unexpected AlarmClient.Subscribe arity {p.Length}; expected 7.");
return new object[]
{
subscription,
(short)1, // wFromPri
(short)999, // wToPri
Enum.ToObject(p[3].ParameterType, (int)eQueryType.qtSummary),
Enum.ToObject(p[4].ParameterType, (int)eSortFlags.sfReturnNewestFirst),
Enum.ToObject(p[5].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow),
Enum.ToObject(p[6].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow),
};
}
/// <inheritdoc />
public void Dispose()
{
lock (syncRoot)
{
if (disposed) return;
disposed = true;
try { if (subscribed) deregisterConsumer?.Invoke(client, Array.Empty<object>()); } catch { /* best effort */ }
try { uninitializeConsumer?.Invoke(client, Array.Empty<object>()); } catch { /* best effort */ }
(client as IDisposable)?.Dispose();
}
}
}
@@ -39,6 +39,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
private readonly Action? threadAffinityCheck;
private readonly IMxAccessComObjectFactory? comFactory;
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
private readonly Func<IManagedAlarmAckClient?>? managedAckFactory;
private readonly MxAccessEventMapper mapper = new MxAccessEventMapper();
private readonly object syncRoot = new object();
private AlarmDispatcher? dispatcher;
@@ -117,18 +118,29 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
/// <paramref name="comFactory"/>. Tests inject a fake so they need no
/// live COM factory.
/// </param>
/// <param name="managedAckFactory">
/// Optional per-subscription factory for a managed ack client
/// (<see cref="IManagedAlarmAckClient"/>). When it yields a non-null
/// client, GUID-keyed acknowledges route through AVEVA's modern
/// <c>AlarmClient.AlarmAckByGUID</c> instead of the wnwrap COM path.
/// Defaults to <see langword="null"/> (wnwrap ack). Production supplies
/// <see cref="AaManagedAlarmAckConsumer.EnvGatedFactory"/>, which is a
/// no-op unless <c>MXGATEWAY_USE_MANAGED_ALARM_ACK=1</c>.
/// </param>
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory,
Action? threadAffinityCheck,
IMxAccessComObjectFactory? comFactory,
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory)
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
Func<IManagedAlarmAckClient?>? managedAckFactory = null)
{
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
this.threadAffinityCheck = threadAffinityCheck;
this.comFactory = comFactory;
this.standbyFactory = standbyFactory;
this.managedAckFactory = managedAckFactory;
}
/// <summary>Gets a value indicating whether the handler is subscribed.</summary>
@@ -169,8 +181,14 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
failover.ProviderModeChanged += providerModeChangedHandler;
}
// Optional managed ack client (opt-in via env); null → wnwrap ack.
// A forced-subtag session never has alarmmgr GUIDs to ack, so it
// skips the extra managed consumer entirely.
IManagedAlarmAckClient? managedAck = command.ForcedMode == AlarmProviderMode.Subtag
? null
: managedAckFactory?.Invoke();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper);
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId);
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, managedAck);
try
{
dispatcher.Subscribe(subscriptionExpression);
@@ -36,20 +36,30 @@ public sealed class AlarmDispatcher : IDisposable
private readonly MxAccessAlarmEventSink sink;
private readonly string sessionId;
private readonly EventHandler<MxAlarmTransitionEvent> handler;
private IManagedAlarmAckClient? managedAck;
private bool disposed;
/// <summary>Initializes a new alarm dispatcher for the given consumer, sink, and session ID.</summary>
/// <param name="consumer">The alarm consumer.</param>
/// <param name="sink">The alarm event sink.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="managedAck">
/// Optional managed ack client. When non-null, GUID-keyed acknowledges
/// route to it (AVEVA's modern <c>AlarmClient.AlarmAckByGUID</c>) instead
/// of the wnwrap consumer while the alarmmgr provider is active; null
/// keeps the existing wnwrap ack path. Subtag-mode acks always stay on
/// the consumer because their GUIDs are synthetic.
/// </param>
public AlarmDispatcher(
IMxAccessAlarmConsumer consumer,
MxAccessAlarmEventSink sink,
string sessionId)
string sessionId,
IManagedAlarmAckClient? managedAck = null)
{
this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
this.sink = sink ?? throw new ArgumentNullException(nameof(sink));
this.sessionId = sessionId ?? string.Empty;
this.managedAck = managedAck;
// Sink.Attach is the seam that propagates the session id onto the
// proto SessionId field of every emitted MxEvent. Pass the consumer
// as the "associated COM object" — sink ignores the object reference
@@ -70,7 +80,32 @@ public sealed class AlarmDispatcher : IDisposable
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
consumer.Subscribe(subscription);
// The managed ack client maintains its own registered/subscribed
// consumer; run its lifecycle against the same expression so a later
// AlarmAckByGUID has a live consumer to act through. It is an opt-in
// enhancement, so a failed lifecycle drops back to the wnwrap ack path
// rather than failing the alarm subscription.
if (managedAck is not null)
{
try
{
managedAck.Subscribe(subscription);
}
catch
{
try { managedAck.Dispose(); } catch { /* swallow */ }
managedAck = null;
}
}
}
/// <summary>
/// True when the consumer is currently serving alarms from the subtag
/// fallback, whose alarm GUIDs are synthetic and unknown to alarmmgr.
/// </summary>
private bool IsSubtagActive() =>
consumer is SubtagAlarmConsumer ||
(consumer is FailoverAlarmConsumer failover && failover.Mode == AlarmProviderMode.Subtag);
/// <summary>
/// Forward an <c>AcknowledgeAlarm</c> request to the underlying
@@ -93,6 +128,22 @@ public sealed class AlarmDispatcher : IDisposable
string ackOperatorFullName)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
if (managedAck is not null && !IsSubtagActive())
{
// Preferred path: AVEVA's modern AlarmClient.AlarmAckByGUID carries
// operator domain + full name and is not E_NOTIMPL like the wnwrap
// COM AlarmAckByGUID. Skipped in subtag mode: those GUIDs are
// synthetic and only the subtag consumer can resolve them (it acks
// via an AckMsg write).
return managedAck.AcknowledgeByGuid(
alarmGuid,
ackComment,
ackOperatorName,
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
}
return consumer.AcknowledgeByGuid(
alarmGuid,
ackComment,
@@ -268,5 +319,6 @@ public sealed class AlarmDispatcher : IDisposable
try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ }
try { sink.Detach(); } catch { /* swallow */ }
try { consumer.Dispose(); } catch { /* swallow */ }
try { managedAck?.Dispose(); } catch { /* swallow */ }
}
}
@@ -0,0 +1,44 @@
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Optional ack-only seam over AVEVA's modern managed alarm client
/// (<c>aaAlarmManagedClient.AlarmClient</c>). When supplied to an
/// <see cref="AlarmDispatcher"/>, GUID-keyed acknowledges route here
/// instead of the wnwrap COM consumer's <c>AlarmAckByGUID</c> (which is
/// <c>E_NOTIMPL</c> on current builds) — carrying full operator identity
/// (domain + full name) that the wnwrap 6-arg <c>AlarmAckByName</c> drops.
/// The production implementation is <see cref="AaManagedAlarmAckConsumer"/>.
/// </summary>
public interface IManagedAlarmAckClient : IDisposable
{
/// <summary>
/// Run the client's Initialize → Register → Subscribe lifecycle against
/// the same subscription expression the read consumer uses
/// (<c>\\&lt;node&gt;\Galaxy!&lt;area&gt;</c>). Must be called once,
/// on the worker STA, before <see cref="AcknowledgeByGuid"/>.
/// </summary>
/// <param name="subscription">The subscription expression.</param>
void Subscribe(string subscription);
/// <summary>
/// Acknowledge a single alarm by GUID with full operator identity.
/// Returns AVEVA's native status (0 = success; 0 means accepted, not
/// necessarily applied — verify the alarm's acked state / Historian row).
/// </summary>
/// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="comment">The acknowledgment comment.</param>
/// <param name="operatorName">The operator name.</param>
/// <param name="operatorNode">The operator node.</param>
/// <param name="operatorDomain">The operator domain.</param>
/// <param name="operatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
int AcknowledgeByGuid(
Guid alarmGuid,
string comment,
string operatorName,
string operatorNode,
string operatorDomain,
string operatorFullName);
}
@@ -0,0 +1,273 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Native alarm-acknowledge path over <c>alarmlst.dll</c>'s public <c>WNAL*</c>
/// C exports (the same stack the AVEVA Alarm Control uses:
/// <c>aaAlarmManagedClient → CwwAlarmConsumer → alarmlst.dll → alarmmgr.exe</c>).
///
/// Why this exists alongside <see cref="WnWrapAlarmConsumer"/>: the legacy
/// <c>WNWRAPCONSUMERLib</c> COM surface cannot carry a full/secured ack —
/// <c>AlarmAckByGUID</c> is <c>E_NOTIMPL</c>, the 8-arg <c>AlarmAckByName</c>
/// is a vendor stub (-55), and neither exposes the signed-ack password. The
/// native request record built by <see cref="WnalReqSetText"/> has six text
/// fields — node, operator, comment, <b>password</b>, domain, full name — so
/// this path can do a fully-attributed (and signed) ack.
///
/// THREADING: these calls are not inherently STA-bound the way the COM
/// consumer is, but keep all of them on the worker STA for ordering/parity
/// with the rest of the alarm surface (marshal via <c>StaRuntime.InvokeAsync</c>).
///
/// x86 ONLY: <c>alarmlst.dll</c> is 32-bit; this compiles into the x86 worker.
///
/// STATUS: the ack-SUBMIT contract (<see cref="WnalReqSetText"/>,
/// <see cref="WnalUserRequest"/>, <see cref="WnalUserRequestEx"/>) and the
/// request-record layout are confirmed by disassembly (see
/// docs/alarm-ack-findings.md in the reverse-engineering workspace). The
/// list-ACQUISITION side (obtaining <c>hAlarmList</c> + alarm handles) is
/// deliberately NOT implemented — see the note at the end of this class.
/// Nothing in the worker calls this type yet; it is kept for when a real
/// list handle can be supplied by a component that owns acquisition.
/// </summary>
public sealed class WnalNativeAckConsumer
{
private const string AlarmLst = "alarmlst.dll";
/// <summary>Request-record version stamped at WORD offset 2; WNALUserRequest rejects anything else.</summary>
private const ushort AlmAckVersion = 0x104;
/// <summary>Byte offset where WNALalarmReqSetText begins the string-offset table + heap.</summary>
private const int StringHeapBase = 0x42;
/// <summary>Sentinel the exports reject as an invalid list handle.</summary>
public const int InvalidAlarmList = -1;
// ---------------------------------------------------------------------
// CONFIRMED exports (disassembly-verified signatures). __stdcall, ANSI.
// ---------------------------------------------------------------------
/// <summary>
/// Pack operator identity + comment into an existing request record.
/// Arg order is confirmed from the debug format string. Per-field rule:
/// pass <c>null</c> to KEEP the record's existing value, "" to CLEAR it,
/// a string to SET it. Strings are single-byte ASCII (strcpy_s).
/// Return: positive = bytes used; NEGATIVE = record too small, magnitude
/// is the required <c>recSize</c>; 0 = <paramref name="lpAlReq"/> was null.
/// </summary>
[DllImport(AlarmLst, EntryPoint = "WNALalarmReqSetText",
CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi, ExactSpelling = true)]
private static extern int WnalReqSetText(
IntPtr lpAlReq,
[MarshalAs(UnmanagedType.LPStr)] string? nodeName,
[MarshalAs(UnmanagedType.LPStr)] string? oper,
[MarshalAs(UnmanagedType.LPStr)] string? comment,
[MarshalAs(UnmanagedType.LPStr)] string? passWd,
[MarshalAs(UnmanagedType.LPStr)] string? domainName,
[MarshalAs(UnmanagedType.LPStr)] string? fullName);
/// <summary>
/// Submit a user request (an ack) over the alarms SELECTED in
/// <paramref name="hAlarmList"/>. a2 is reserved (0); a3 is a capacity
/// hint (the native client passes the grid row count); a4 is scope
/// (0 = all, 1 = selected). Returns 0 on success (provider status),
/// non-zero on error.
/// </summary>
[DllImport(AlarmLst, EntryPoint = "WNALUserRequest",
CallingConvention = CallingConvention.StdCall, ExactSpelling = true)]
private static extern int WnalUserRequest(int hAlarmList, uint a2, uint a3, int a4, IntPtr lpAlmAck);
/// <summary>
/// Submit a user request (an ack) against an EXPLICIT array of
/// <paramref name="count"/> 4-byte alarm handles — no selection state
/// required. <paramref name="a4"/> mirrors the scope/flag scalar.
/// Returns 0 on success.
/// </summary>
[DllImport(AlarmLst, EntryPoint = "WNALUserRequestEx",
CallingConvention = CallingConvention.StdCall, ExactSpelling = true)]
private static extern int WnalUserRequestEx(
int hAlarmList, IntPtr handleArray, int count, uint a4, IntPtr lpAlmAck);
/// <summary>Acknowledge-scope values for the a4 argument.</summary>
private enum AckScope
{
All = 0,
Selected = 1,
}
/// <summary>
/// Build a request record and submit an ack for the given alarm
/// handles via <see cref="WnalUserRequestEx"/>. Caller supplies a live
/// <paramref name="hAlarmList"/> and the handle(s) obtained from the
/// list's records (acquisition is not implemented — see the class note).
/// Returns the native status (0 == success).
/// </summary>
/// <param name="hAlarmList">A registered, subscribed alarm-list handle.</param>
/// <param name="alarmHandles">The 4-byte alarm handles to acknowledge.</param>
/// <param name="comment">Ack comment.</param>
/// <param name="operatorName">Operator / user name.</param>
/// <param name="operatorNode">Operator node name.</param>
/// <param name="operatorDomain">Operator domain (carried natively, unlike the COM path).</param>
/// <param name="operatorFullName">Operator full name (carried natively, unlike the COM path).</param>
/// <param name="password">Operator password for a signed/secured ack, or null.</param>
public int AcknowledgeByHandles(
int hAlarmList,
int[] alarmHandles,
string comment,
string operatorName,
string operatorNode,
string operatorDomain,
string operatorFullName,
string? password)
{
if (hAlarmList == InvalidAlarmList)
throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList));
if (alarmHandles is null || alarmHandles.Length == 0)
throw new ArgumentException("At least one alarm handle is required.", nameof(alarmHandles));
IntPtr record = IntPtr.Zero;
IntPtr handles = IntPtr.Zero;
try
{
record = BuildAckRecord(
nodeName: operatorNode,
oper: operatorName,
comment: comment,
passWd: password,
domainName: operatorDomain,
fullName: operatorFullName);
handles = Marshal.AllocHGlobal(alarmHandles.Length * sizeof(int));
for (int i = 0; i < alarmHandles.Length; i++)
Marshal.WriteInt32(handles, i * sizeof(int), alarmHandles[i]);
return WnalUserRequestEx(
hAlarmList,
handles,
alarmHandles.Length,
(uint)AckScope.Selected,
record);
}
finally
{
if (handles != IntPtr.Zero) Marshal.FreeHGlobal(handles);
if (record != IntPtr.Zero) Marshal.FreeHGlobal(record);
}
}
/// <summary>
/// Acknowledge whatever is currently SELECTED in the list (a4 = Selected)
/// via <see cref="WnalUserRequest"/>. Use when selection has already been
/// established on the list rather than passing explicit handles.
/// </summary>
public int AcknowledgeSelected(
int hAlarmList,
uint rowCountHint,
string comment,
string operatorName,
string operatorNode,
string operatorDomain,
string operatorFullName,
string? password)
{
if (hAlarmList == InvalidAlarmList)
throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList));
IntPtr record = IntPtr.Zero;
try
{
record = BuildAckRecord(operatorNode, operatorName, comment, password, operatorDomain, operatorFullName);
return WnalUserRequest(hAlarmList, a2: 0, a3: rowCountHint, a4: (int)AckScope.Selected, record);
}
finally
{
if (record != IntPtr.Zero) Marshal.FreeHGlobal(record);
}
}
/// <summary>
/// Allocate a zeroed request record (header + room for the string heap),
/// stamp recSize + version, then let <see cref="WnalReqSetText"/> pack the
/// operator/comment text. Returns an unmanaged buffer the caller must free.
/// </summary>
private static IntPtr BuildAckRecord(
string nodeName, string oper, string comment, string? passWd, string domainName, string fullName)
{
// recSize must be >= 0x42 + sum(strlen+1 of each field). Size generously;
// SetText returns a negative "needed size" if this is too small, which we
// honor by growing once.
int capacity = EstimateCapacity(nodeName, oper, comment, passWd, domainName, fullName);
for (int attempt = 0; attempt < 2; attempt++)
{
IntPtr buf = Marshal.AllocHGlobal(capacity);
try
{
// Zero the whole record (fixed header 0x04..0x31 stays 0 — WNALUserRequest
// does not validate it; it is informational/provider-filled).
for (int off = 0; off < capacity; off += sizeof(long))
{
int remaining = capacity - off;
if (remaining >= sizeof(long)) Marshal.WriteInt64(buf, off, 0);
else for (int b = off; b < capacity; b++) Marshal.WriteByte(buf, b, 0);
}
Marshal.WriteInt16(buf, 0, (short)(ushort)capacity); // WORD recSize
Marshal.WriteInt16(buf, 2, unchecked((short)AlmAckVersion)); // WORD version = 0x104
int rc = WnalReqSetText(buf, nodeName, oper, comment, passWd, domainName, fullName);
if (rc >= 0)
return buf;
// rc < 0 => record too small; |rc| is the required size. Grow and retry.
capacity = -rc;
Marshal.FreeHGlobal(buf);
}
catch
{
Marshal.FreeHGlobal(buf);
throw;
}
}
throw new InvalidOperationException("WNALalarmReqSetText could not size the ack record.");
}
private static int EstimateCapacity(params string?[] fields)
{
int bytes = StringHeapBase;
foreach (string? f in fields)
bytes += (f is null ? 0 : Encoding.ASCII.GetByteCount(f)) + 1;
// Round up with headroom; records are tiny, over-allocation is cheap.
return Math.Max(1024, bytes + 64);
}
// ---------------------------------------------------------------------
// LIST ACQUISITION — intentionally NOT implemented here, by RE conclusion.
//
// The ABIs of WNALSelectEntry (func 0x1d) and WNALGetAlarmRecordEx (func
// 0x1c) are recoverable, but their SEMANTICS are not: WNALGetAlarmRecordEx
// does not define the alarm-record layout — it hands alarmmgr/the provider a
// set of opaque output buffers (four 0x41-byte string buffers, a 0x8b-byte
// buffer, scalars) that the PROVIDER fills. So the record's GUID offset and
// the 4-byte alarm handle that WNALUserRequestEx consumes are provider-defined
// and cannot be read out of alarmlst.dll; likewise WNALSelectEntry's arg
// meanings live on the provider side of MakeAlarmMgrFuncCall. Hand-rolling the
// acquisition from static RE alone would be guessing which bytes are the
// handle/GUID — a wrong guess acks the wrong alarm.
//
// Therefore DO NOT complete the native acquisition by inference. Obtain the
// hAlarmList + handles from a component that already encapsulates
// register/query/match correctly:
// * Preferred: aaAlarmManagedClient.dll AlarmClient.AlarmAckByGUID
// (int AlarmAckByGUID(Guid, comment, oper, node, domain, fullName)) — the
// Alarm Control's own code; managed signatures are exact (no ABI risk).
// It is internal, so invoke by reflection from the x86 worker.
// * Or drive ack through the existing WnWrapAlarmConsumer, which already
// owns a registered/subscribed consumer, and fix the auth gap (Stage 0).
// Keep the confirmed SUBMIT methods above for the case where a real
// hAlarmList + handles are supplied by such a component.
// ---------------------------------------------------------------------
}