feat(worker): route alarm acks through the operator-authenticated AckMsg writer
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 2m5s
ci / windows-x86 (push) Successful in 2m10s
ci / portable (push) Failing after 2m17s

AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
Joseph Doherty
2026-10-19 19:35:55 -04:00
parent 0d4b2aea2d
commit d6db0ff40d
14 changed files with 695 additions and 1282 deletions
@@ -116,7 +116,7 @@ public sealed class AlarmCommandExecutorTests
[Fact] [Fact]
public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload() public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload()
{ {
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 }; FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler); MxAccessCommandExecutor executor = NewExecutor(handler);
Guid g = Guid.NewGuid(); Guid g = Guid.NewGuid();
@@ -143,7 +143,10 @@ public sealed class AlarmCommandExecutorTests
Assert.NotNull(reply.AcknowledgeAlarm); Assert.NotNull(reply.AcknowledgeAlarm);
Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus); Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus);
Assert.Equal(g, handler.LastAckGuid); Assert.Equal(g, handler.LastAckGuid);
Assert.Equal("alice", handler.LastAckOperatorName); Assert.Equal("ack", handler.LastComment);
Assert.Equal("alice", handler.LastOperator!.UserName);
Assert.Equal("CORP", handler.LastOperator.Domain);
Assert.Equal(string.Empty, handler.LastOperator.Password);
} }
/// <summary>Verifies that invalid alarm GUID returns invalid request.</summary> /// <summary>Verifies that invalid alarm GUID returns invalid request.</summary>
@@ -168,11 +171,14 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
} }
/// <summary>Verifies that nonzero native status carries a diagnostic message.</summary> /// <summary>Verifies that a rejected write carries its native status and diagnostic.</summary>
[Fact] [Fact]
public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic() public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic()
{ {
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = -123 }; FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(AlarmAckOutcome.WriteRejected, -123, "AckMsg write rejected: detail -123."),
};
MxAccessCommandExecutor executor = NewExecutor(handler); MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand( StaCommand command = new StaCommand(
@@ -188,7 +194,9 @@ public sealed class AlarmCommandExecutorTests
MxCommandReply reply = executor.Execute(command); MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Equal(-123, reply.Hresult); Assert.Equal(-123, reply.Hresult);
Assert.Equal(-123, reply.AcknowledgeAlarm.NativeStatus);
Assert.Contains("-123", reply.DiagnosticMessage); Assert.Contains("-123", reply.DiagnosticMessage);
} }
@@ -196,7 +204,7 @@ public sealed class AlarmCommandExecutorTests
[Fact] [Fact]
public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler() public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler()
{ {
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 }; FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler); MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand( StaCommand command = new StaCommand(
@@ -223,7 +231,133 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name); Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name);
Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider); Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider);
Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group); Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group);
Assert.Equal("alice", handler.LastAckOperatorName); Assert.Equal("ack", handler.LastComment);
Assert.Equal("alice", handler.LastOperator!.UserName);
}
/// <summary>
/// Every ack outcome maps to its protocol status, carries the native
/// status, and never echoes the operator password.
/// </summary>
/// <param name="outcome">The writer outcome.</param>
/// <param name="expected">The expected protocol status.</param>
[Theory]
[InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
[InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
public void AcknowledgeAlarm_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
{
int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = Guid.NewGuid().ToString(),
Comment = "checked",
OperatorUser = "alice",
OperatorDomain = "CORP",
OperatorPassword = "pw-secret",
},
});
AssertAckReply(reply, handler, expected, native);
}
/// <summary>The by-name ack maps outcomes identically and never echoes the operator password.</summary>
/// <param name="outcome">The writer outcome.</param>
/// <param name="expected">The expected protocol status.</param>
[Theory]
[InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
[InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
public void AcknowledgeAlarmByName_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
{
int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = "TestMachine_001.TestAlarm001",
ProviderName = "Galaxy",
GroupName = "TestArea",
Comment = "checked",
OperatorUser = "alice",
OperatorDomain = "CORP",
OperatorPassword = "pw-secret",
},
});
AssertAckReply(reply, handler, expected, native);
}
/// <summary>The writer's OnWriteComplete statuses are copied onto the reply.</summary>
[Fact]
public void AcknowledgeAlarm_CopiesWriteStatusesIntoReply()
{
MxStatusProxy status = new MxStatusProxy { Category = MxStatusCategory.Ok, Success = 1 };
FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, new[] { status }),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand { AlarmGuid = Guid.NewGuid().ToString(), OperatorUser = "alice" },
});
Assert.Equal(status, Assert.Single(reply.Statuses));
}
/// <summary>
/// An exception escaping the ack path (e.g. the writer's MXAccess
/// Register failing) maps to MxaccessFailure with only the exception type
/// and HRESULT — never the exception text, which is not under our control.
/// </summary>
[Fact]
public void AcknowledgeAlarmByName_WhenHandlerThrows_ReportsTypeAndHresultOnly()
{
FakeAlarmHandler handler = new FakeAlarmHandler
{
AcknowledgeException = new System.Runtime.InteropServices.COMException("echo pw-secret", unchecked((int)0x80004005)),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = "TestMachine_001.TestAlarm001",
ProviderName = "Galaxy",
OperatorUser = "alice",
OperatorPassword = "pw-secret",
},
});
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Equal(unchecked((int)0x80004005), reply.Hresult);
Assert.Contains("COMException", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.Contains("0x80004005", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message, StringComparison.Ordinal);
} }
/// <summary>Verifies that empty alarm name returns invalid request.</summary> /// <summary>Verifies that empty alarm name returns invalid request.</summary>
@@ -393,7 +527,10 @@ public sealed class AlarmCommandExecutorTests
[Fact] [Fact]
public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure() public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure()
{ {
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeThrow = true }; FakeAlarmHandler handler = new FakeAlarmHandler
{
AcknowledgeException = new InvalidOperationException("simulated alarm-handler failure"),
};
MxAccessCommandExecutor executor = NewExecutor(handler); MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand( StaCommand command = new StaCommand(
@@ -410,7 +547,25 @@ public sealed class AlarmCommandExecutorTests
MxCommandReply reply = executor.Execute(command); MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Contains("simulated", reply.DiagnosticMessage); Assert.Contains("InvalidOperationException", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.DoesNotContain("simulated", reply.DiagnosticMessage, StringComparison.Ordinal);
}
private static MxCommandReply Execute(IAlarmCommandHandler handler, MxCommand command) =>
NewExecutor(handler).Execute(new StaCommand(SessionId, CorrelationId, command));
private static void AssertAckReply(MxCommandReply reply, FakeAlarmHandler handler, ProtocolStatusCode expected, int native)
{
Assert.Equal(expected, reply.ProtocolStatus.Code);
Assert.Equal(native, reply.Hresult);
Assert.Equal(native, reply.AcknowledgeAlarm.NativeStatus);
Assert.Equal("diagnostic for alice", reply.DiagnosticMessage);
Assert.Equal("checked", handler.LastComment);
Assert.Equal("alice", handler.LastOperator!.UserName);
Assert.Equal("CORP", handler.LastOperator.Domain);
Assert.Equal("pw-secret", handler.LastOperator.Password);
Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal);
Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal);
} }
private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler) private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler)
@@ -442,14 +597,17 @@ public sealed class AlarmCommandExecutorTests
/// <summary>Gets the last acknowledge alarm GUID.</summary> /// <summary>Gets the last acknowledge alarm GUID.</summary>
public Guid LastAckGuid { get; private set; } public Guid LastAckGuid { get; private set; }
/// <summary>Gets the last acknowledge operator name.</summary> /// <summary>Gets the last acknowledge comment.</summary>
public string? LastAckOperatorName { get; private set; } public string? LastComment { get; private set; }
/// <summary>Gets or sets the value returned by acknowledge.</summary> /// <summary>Gets the last acknowledge operator.</summary>
public int AcknowledgeReturn { get; set; } public AlarmAckOperator? LastOperator { get; private set; }
/// <summary>Gets or sets a value indicating whether acknowledge throws.</summary> /// <summary>Gets or sets the result returned by both acknowledge methods.</summary>
public bool AcknowledgeThrow { get; set; } public AlarmAckResult AckResult { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
/// <summary>Gets or sets an exception both acknowledge methods throw.</summary>
public Exception? AcknowledgeException { get; set; }
/// <summary>Gets or sets the query result snapshots.</summary> /// <summary>Gets or sets the query result snapshots.</summary>
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } = public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
@@ -484,28 +642,32 @@ public sealed class AlarmCommandExecutorTests
} }
/// <inheritdoc /> /// <inheritdoc />
public int Acknowledge( public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
Guid alarmGuid, string comment, string operatorUser,
string operatorNode, string operatorDomain, string operatorFullName)
{ {
LastAckGuid = alarmGuid; LastAckGuid = alarmGuid;
LastAckOperatorName = operatorUser; LastComment = comment;
if (AcknowledgeThrow) LastOperator = ackOperator;
if (AcknowledgeException is not null)
{ {
throw new InvalidOperationException("simulated alarm-handler failure"); throw AcknowledgeException;
} }
return AcknowledgeReturn;
return AckResult;
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckResult AcknowledgeByName(
string alarmName, string providerName, string groupName, string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator)
string comment, string operatorUser, string operatorNode,
string operatorDomain, string operatorFullName)
{ {
LastAckByNameTuple = (alarmName, providerName, groupName); LastAckByNameTuple = (alarmName, providerName, groupName);
LastAckOperatorName = operatorUser; LastComment = comment;
return AcknowledgeReturn; LastOperator = ackOperator;
if (AcknowledgeException is not null)
{
throw AcknowledgeException;
}
return AckResult;
} }
/// <summary>Gets the last acknowledge by name tuple.</summary> /// <summary>Gets the last acknowledge by name tuple.</summary>
@@ -96,22 +96,100 @@ public sealed class AlarmCommandHandlerTests
Assert.False(handler.IsSubscribed); Assert.False(handler.IsSubscribed);
} }
/// <summary>Verifies that acknowledge forwards to consumer with full operator identity when subscribed.</summary> /// <summary>Verifies that acknowledge resolves via the consumer and writes through the injected writer as the operator.</summary>
[Fact] [Fact]
public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity() public void Acknowledge_WhenSubscribed_ResolvesTargetAndWritesAsOperator()
{ {
FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 }; FakeConsumer consumer = new FakeConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
AlarmCommandHandler handler = new AlarmCommandHandler( RecordingAckWriter writer = new RecordingAckWriter();
new MxAccessEventQueue(), AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer);
() => consumer);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Guid g = Guid.NewGuid(); Guid g = Guid.NewGuid();
int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F"); AlarmAckOperator op = new AlarmAckOperator { UserName = "u", Domain = "d", Password = "p" };
AlarmAckResult result = handler.Acknowledge(g, "c", op);
Assert.Equal(0, rc); Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal(g, consumer.LastAckGuid); Assert.Equal(g, consumer.LastResolvedGuid);
Assert.Equal("u", consumer.LastAckOperatorName); Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
Assert.Equal("c", writer.LastComment);
Assert.Same(op, writer.LastOperator);
}
/// <summary>Verifies acknowledge-by-name forwards the tuple and a null comment becomes empty.</summary>
[Fact]
public void AcknowledgeByName_WhenSubscribed_ForwardsTupleAndNormalizesComment()
{
FakeConsumer consumer = new FakeConsumer();
RecordingAckWriter writer = new RecordingAckWriter();
AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
handler.AcknowledgeByName("Tag1", "Galaxy", "Area", null!, new AlarmAckOperator { UserName = "u" });
Assert.Equal(("Tag1", "Galaxy", "Area"), consumer.LastResolvedName);
Assert.Equal(string.Empty, writer.LastComment);
}
/// <summary>Verifies a null operator is rejected.</summary>
[Fact]
public void Acknowledge_NullOperator_Throws()
{
AlarmCommandHandler handler = NewHandlerWithWriter(() => new FakeConsumer(), new RecordingAckWriter());
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Throws<ArgumentNullException>(() => handler.Acknowledge(Guid.NewGuid(), "c", null!));
}
/// <summary>
/// The ack writer is created once, on first acknowledgement, survives an
/// unsubscribe/resubscribe cycle, and is disposed with the handler.
/// </summary>
[Fact]
public void AckWriter_CreatedLazilyOnce_AndDisposedWithHandler()
{
RecordingAckWriter writer = new RecordingAckWriter();
int created = 0;
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => new FakeConsumer(),
threadAffinityCheck: null,
comFactory: null,
standbyFactory: null,
ackWriterFactory: () =>
{
created++;
return writer;
});
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Equal(0, created);
handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator());
handler.Unsubscribe();
Assert.False(writer.Disposed);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
handler.AcknowledgeByName("a", "Galaxy", "g", "c", new AlarmAckOperator());
Assert.Equal(1, created);
handler.Dispose();
Assert.True(writer.Disposed);
}
/// <summary>
/// Without a COM factory or writer factory, subscribing still works and
/// the acknowledgement fails with a diagnosable InvalidOperationException.
/// </summary>
[Fact]
public void Acknowledge_WithoutComFactoryOrWriterFactory_ThrowsInvalidOperation()
{
AlarmCommandHandler handler = new AlarmCommandHandler(new MxAccessEventQueue(), () => new FakeConsumer());
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator()));
Assert.Contains("IMxAccessComObjectFactory", ex.Message);
} }
/// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary> /// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary>
@@ -123,7 +201,7 @@ public sealed class AlarmCommandHandlerTests
() => new FakeConsumer()); () => new FakeConsumer());
Assert.Throws<InvalidOperationException>( Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); () => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
} }
/// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary> /// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary>
@@ -266,7 +344,10 @@ public sealed class AlarmCommandHandlerTests
AlarmCommandHandler handler = new AlarmCommandHandler( AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(), new MxAccessEventQueue(),
() => consumer, () => consumer,
() => guardInvocations++); () => guardInvocations++,
comFactory: null,
standbyFactory: null,
ackWriterFactory: () => new RecordingAckWriter());
// Subscribe is the first call — guard must run before the consumer // Subscribe is the first call — guard must run before the consumer
// factory is invoked. We tally invocation counts after each call so // factory is invoked. We tally invocation counts after each call so
@@ -275,10 +356,10 @@ public sealed class AlarmCommandHandlerTests
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Equal(1, guardInvocations); Assert.Equal(1, guardInvocations);
handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F"); handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "u" });
Assert.Equal(2, guardInvocations); Assert.Equal(2, guardInvocations);
handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F"); handler.AcknowledgeByName("a", "p", "g", "c", new AlarmAckOperator { UserName = "u" });
Assert.Equal(3, guardInvocations); Assert.Equal(3, guardInvocations);
_ = handler.QueryActive(null, out _); _ = handler.QueryActive(null, out _);
@@ -323,9 +404,9 @@ public sealed class AlarmCommandHandlerTests
// assert each independently with a fresh handler. Below we reuse // assert each independently with a fresh handler. Below we reuse
// the same throwing handler for the not-subscribed-yet entries: // the same throwing handler for the not-subscribed-yet entries:
Assert.Throws<InvalidOperationException>( Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); () => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
Assert.Throws<InvalidOperationException>( Assert.Throws<InvalidOperationException>(
() => handler.AcknowledgeByName("", "", "", "", "", "", "", "")); () => handler.AcknowledgeByName("", "", "", "", new AlarmAckOperator()));
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _)); Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _));
Assert.Throws<InvalidOperationException>(() => handler.PollOnce()); Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
Assert.Throws<InvalidOperationException>( Assert.Throws<InvalidOperationException>(
@@ -422,16 +503,17 @@ public sealed class AlarmCommandHandlerTests
[Fact] [Fact]
public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby() public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby()
{ {
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 }; FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
FakeConsumer initialStandby = new FakeConsumer { AcknowledgeReturn = 22 }; FakeConsumer initialStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 }; FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0; int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler( AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(), new MxAccessEventQueue(),
() => primary, () => primary,
threadAffinityCheck: null, threadAffinityCheck: null,
comFactory: null, comFactory: null,
standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement); standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement,
ackWriterFactory: () => new RecordingAckWriter());
handler.Subscribe(new SubscribeAlarmsCommand handler.Subscribe(new SubscribeAlarmsCommand
{ {
@@ -443,7 +525,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(2, buildCount); Assert.Equal(2, buildCount);
Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription); Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription);
Assert.True(initialStandby.Disposed); Assert.True(initialStandby.Disposed);
Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); Assert.Equal("11.AckMsg", AckItemOf(handler));
} }
/// <summary>A replacement is rejected when no fallback-capable subscription is active.</summary> /// <summary>A replacement is rejected when no fallback-capable subscription is active.</summary>
@@ -471,16 +553,17 @@ public sealed class AlarmCommandHandlerTests
[Fact] [Fact]
public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode() public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode()
{ {
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 }; FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 }; FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 33 }; FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0; int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler( AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(), new MxAccessEventQueue(),
() => primary, () => primary,
null, null,
null, null,
_ => ++buildCount == 1 ? oldStandby : failedReplacement); _ => ++buildCount == 1 ? oldStandby : failedReplacement,
() => new RecordingAckWriter());
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
{ {
SubscriptionExpression = @"\\HOST\Galaxy!Area", SubscriptionExpression = @"\\HOST\Galaxy!Area",
@@ -494,23 +577,24 @@ public sealed class AlarmCommandHandlerTests
Assert.False(oldStandby.Disposed); Assert.False(oldStandby.Disposed);
Assert.True(failedReplacement.Disposed); Assert.True(failedReplacement.Disposed);
Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); Assert.Equal("11.AckMsg", AckItemOf(handler));
} }
/// <summary>A successful replacement while failed over keeps subtag mode active and delegates to the new standby.</summary> /// <summary>A successful replacement while failed over keeps subtag mode active and delegates to the new standby.</summary>
[Fact] [Fact]
public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement() public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement()
{ {
FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 11 }; FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("11.AckMsg") };
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 }; FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 }; FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0; int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler( AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(), new MxAccessEventQueue(),
() => primary, () => primary,
null, null,
null, null,
_ => ++buildCount == 1 ? oldStandby : replacement); _ => ++buildCount == 1 ? oldStandby : replacement,
() => new RecordingAckWriter());
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
{ {
SubscriptionExpression = @"\\HOST\Galaxy!Area", SubscriptionExpression = @"\\HOST\Galaxy!Area",
@@ -523,7 +607,7 @@ public sealed class AlarmCommandHandlerTests
handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") }); handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") });
Assert.True(oldStandby.Disposed); Assert.True(oldStandby.Disposed);
Assert.Equal(33, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); Assert.Equal("33.AckMsg", AckItemOf(handler));
} }
/// <summary> /// <summary>
@@ -567,6 +651,19 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime()); Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime());
} }
private static AlarmCommandHandler NewHandlerWithWriter(Func<IMxAccessAlarmConsumer> consumerFactory, IAlarmAckWriter writer) =>
new AlarmCommandHandler(
new MxAccessEventQueue(),
consumerFactory,
threadAffinityCheck: null,
comFactory: null,
standbyFactory: null,
ackWriterFactory: () => writer);
/// <summary>Acks through the handler and returns the item the active consumer resolved (the fake writer echoes it as the diagnostic).</summary>
private static string AckItemOf(AlarmCommandHandler handler) =>
handler.Acknowledge(Guid.Empty, string.Empty, new AlarmAckOperator()).Diagnostic;
private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag) private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag)
{ {
return new MxAlarmSnapshotRecord return new MxAlarmSnapshotRecord
@@ -598,12 +695,12 @@ public sealed class AlarmCommandHandlerTests
/// <summary>Gets the last subscription request.</summary> /// <summary>Gets the last subscription request.</summary>
public string? LastSubscription { get; private set; } public string? LastSubscription { get; private set; }
/// <summary>Gets the last acknowledged alarm GUID.</summary> /// <summary>Gets or sets the ack target both resolve methods return.</summary>
public Guid LastAckGuid { get; private set; } public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
/// <summary>Gets the last acknowledged operator name.</summary> /// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
public string? LastAckOperatorName { get; private set; } public Guid LastResolvedGuid { get; private set; }
/// <summary>Gets or sets the return value for acknowledge operations.</summary> /// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
public int AcknowledgeReturn { get; set; } public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
/// <summary>Gets or sets the snapshot result to return.</summary> /// <summary>Gets or sets the snapshot result to return.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } = public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>(); Array.Empty<MxAlarmSnapshotRecord>();
@@ -623,29 +720,19 @@ public sealed class AlarmCommandHandlerTests
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid( public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
Guid alarmGuid, string ackComment, string ackOperatorName,
string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
{ {
LastAckGuid = alarmGuid; LastResolvedGuid = alarmGuid;
LastAckOperatorName = ackOperatorName; return AckTarget;
return AcknowledgeReturn;
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
string ackOperatorDomain, string ackOperatorFullName)
{ {
LastAckByNameTuple = (alarmName, providerName, groupName); LastResolvedName = (alarmName, providerName, groupName);
LastAckOperatorName = ackOperatorName; return AckTarget;
return AcknowledgeReturn;
} }
/// <summary>Gets the last acknowledge-by-name parameters.</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary> /// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
public bool SnapshotTruncated { get; set; } public bool SnapshotTruncated { get; set; }
@@ -671,4 +758,22 @@ public sealed class AlarmCommandHandlerTests
Disposed = true; Disposed = true;
} }
} }
private sealed class RecordingAckWriter : IAlarmAckWriter
{
public string? LastItemAddress { get; private set; }
public string? LastComment { get; private set; }
public AlarmAckOperator? LastOperator { get; private set; }
public bool Disposed { get; private set; }
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
{
LastItemAddress = itemAddress;
LastComment = comment;
LastOperator = ackOperator;
return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, itemAddress);
}
public void Dispose() => Disposed = true;
}
} }
@@ -24,7 +24,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer(); FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent consumer.RaiseTransition(new MxAlarmTransitionEvent
{ {
@@ -56,7 +56,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer(); FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc); DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
consumer.RaiseTransition(new MxAlarmTransitionEvent consumer.RaiseTransition(new MxAlarmTransitionEvent
@@ -142,7 +142,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer(); FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent consumer.RaiseTransition(new MxAlarmTransitionEvent
{ {
@@ -177,7 +177,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer(); FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent consumer.RaiseTransition(new MxAlarmTransitionEvent
{ {
@@ -205,155 +205,140 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
new FakeAckWriter());
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription); Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
} }
/// <summary>Verifies that acknowledge calls are forwarded to the consumer with operator identity.</summary> /// <summary>Verifies the dispatcher resolves the ack item via the consumer then writes it as the operator.</summary>
[Fact] [Fact]
public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity() public void Acknowledge_ResolvesTargetThenWritesWithOperator()
{ {
FakeAlarmConsumer consumer = new FakeAlarmConsumer(); FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
consumer.AcknowledgeReturn = 0; FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
Guid guid = Guid.NewGuid(); Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge( AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" };
guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
Assert.Equal(0, rc); AlarmAckResult result = dispatcher.Acknowledge(guid, "checked", op);
Assert.Equal(guid, consumer.LastAckGuid);
Assert.Equal("Acked", consumer.LastAckComment); Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal("alice", consumer.LastAckOperatorName); Assert.Equal(guid, consumer.LastResolvedGuid);
Assert.Equal("WS01", consumer.LastAckOperatorNode); Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
Assert.Equal("CORP", consumer.LastAckOperatorDomain); Assert.Equal("checked", writer.LastComment);
Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName); Assert.Same(op, writer.LastOperator);
} }
/// <summary>Verifies that a managed ack client takes GUID acks with full operator identity.</summary> /// <summary>Verifies an unresolved target short-circuits to its failure without calling the writer.</summary>
[Fact] [Fact]
public void Acknowledge_WithManagedAckClient_RoutesGuidAckToManagedClient() public void Acknowledge_WhenTargetNotFound_DoesNotWrite()
{ {
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = -1 }; FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotFound("gone") };
FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = 0 }; FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
managed);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Guid guid = Guid.NewGuid(); AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" });
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
Assert.Equal(0, rc); Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome);
Assert.Equal(@"\\HOST\Galaxy!Area1", managed.LastSubscription); Assert.Equal("gone", result.Diagnostic);
Assert.Equal(guid, managed.LastAckGuid); Assert.Null(writer.LastItemAddress);
Assert.Equal("CORP", managed.LastAckOperatorDomain);
Assert.Equal("Alice Smith", managed.LastAckOperatorFullName);
Assert.Equal(Guid.Empty, consumer.LastAckGuid);
} }
/// <summary> /// <summary>Verifies acknowledge-by-name resolves with the full tuple and writes the resolved item.</summary>
/// Verifies that a failed managed-client lifecycle neither fails the alarm
/// subscription nor leaves acks routed to the broken client.
/// </summary>
[Fact] [Fact]
public void Subscribe_WhenManagedAckClientFails_FallsBackToConsumerAck() public void AcknowledgeByName_ResolvesTupleThenWritesWithOperator()
{ {
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; FakeAlarmConsumer consumer = new FakeAlarmConsumer
FakeManagedAckClient managed = new FakeManagedAckClient { ThrowOnSubscribe = true }; {
AckTarget = AlarmAckTarget.For("TestMachine_001.TestAlarm001.AckMsg"),
};
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Domain = "CORP" };
SessionId,
managed);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); AlarmAckResult result = dispatcher.AcknowledgeByName(
Guid guid = Guid.NewGuid(); "TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "ack", op);
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription); Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.True(managed.Disposed); Assert.Equal(("TestMachine_001.TestAlarm001", "Galaxy", "TestArea"), consumer.LastResolvedName);
Assert.Equal(0, rc); Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", writer.LastItemAddress);
Assert.Equal(guid, consumer.LastAckGuid); Assert.Equal("ack", writer.LastComment);
Assert.Equal(Guid.Empty, managed.LastAckGuid); Assert.Same(op, writer.LastOperator);
}
/// <summary>Verifies a NotAckable by-name target short-circuits without calling the writer.</summary>
[Fact]
public void AcknowledgeByName_WhenNotAckable_DoesNotWrite()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotAckable("intouch") };
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
AlarmAckResult result = dispatcher.AcknowledgeByName("Tag1", "InTouch", "G", "c", new AlarmAckOperator());
Assert.Equal(AlarmAckOutcome.NotAckable, result.Outcome);
Assert.Null(writer.LastItemAddress);
} }
/// <summary> /// <summary>
/// Verifies that while the failover consumer serves the subtag standby, /// Verifies that while the failover consumer serves the subtag standby,
/// GUID acks (synthetic GUIDs) stay on the consumer, not the managed client. /// GUID acks resolve through the standby (its synthetic GUIDs).
/// </summary> /// </summary>
[Fact] [Fact]
public void Acknowledge_WhenFailoverInSubtagMode_KeepsGuidAckOnConsumer() public void Acknowledge_WhenFailoverInSubtagMode_ResolvesThroughStandby()
{ {
FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true }; FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true };
FakeAlarmConsumer standby = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; FakeAlarmConsumer standby = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("Standby.AckMsg") };
FailoverAlarmConsumer failover = new FailoverAlarmConsumer( FailoverAlarmConsumer failover = new FailoverAlarmConsumer(
primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3)); primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3));
FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = -1 }; FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
failover, failover,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId, SessionId,
managed); writer);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Assert.Equal(AlarmProviderMode.Subtag, failover.Mode); Assert.Equal(AlarmProviderMode.Subtag, failover.Mode);
Guid guid = Guid.NewGuid(); Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); dispatcher.Acknowledge(guid, "Acked", new AlarmAckOperator { UserName = "alice" });
Assert.Equal(0, rc); Assert.Equal(guid, standby.LastResolvedGuid);
Assert.Equal(guid, standby.LastAckGuid); Assert.Equal(Guid.Empty, primary.LastResolvedGuid);
Assert.Equal(Guid.Empty, managed.LastAckGuid); Assert.Equal("Standby.AckMsg", writer.LastItemAddress);
} }
/// <summary>Verifies that disposing the dispatcher disposes the managed ack client.</summary> /// <summary>Verifies the dispatcher does not dispose the writer: the handler owns it.</summary>
[Fact] [Fact]
public void Dispose_WithManagedAckClient_DisposesManagedClient() public void Dispose_DoesNotDisposeAckWriter()
{ {
FakeManagedAckClient managed = new FakeManagedAckClient(); FakeAckWriter writer = new FakeAckWriter();
AlarmDispatcher dispatcher = new AlarmDispatcher( AlarmDispatcher dispatcher = new AlarmDispatcher(
new FakeAlarmConsumer(), new FakeAlarmConsumer(),
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId, SessionId,
managed); writer);
dispatcher.Dispose(); dispatcher.Dispose();
Assert.True(managed.Disposed); Assert.False(writer.Disposed);
} }
/// <summary>Verifies that acknowledge-by-name calls are forwarded to the consumer.</summary> /// <summary>Verifies the constructor rejects a null ack writer.</summary>
[Fact] [Fact]
public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple() public void Constructor_NullAckWriter_Throws()
{ {
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; Assert.Throws<ArgumentNullException>(() => new AlarmDispatcher(
using AlarmDispatcher dispatcher = new AlarmDispatcher( new FakeAlarmConsumer(),
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
null!));
int rc = dispatcher.AcknowledgeByName(
alarmName: "TestMachine_001.TestAlarm001",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "ack",
ackOperatorName: "alice",
ackOperatorNode: "WS",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
Assert.Equal(0, rc);
Assert.NotNull(consumer.LastAckByNameTuple);
Assert.Equal("TestMachine_001.TestAlarm001", consumer.LastAckByNameTuple!.Value.Name);
Assert.Equal("Galaxy", consumer.LastAckByNameTuple!.Value.Provider);
Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group);
} }
/// <summary>Verifies that consumer alarm records are mapped to proto snapshots.</summary> /// <summary>Verifies that consumer alarm records are mapped to proto snapshots.</summary>
@@ -391,7 +376,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _); IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
Assert.Equal(2, snapshots.Count); Assert.Equal(2, snapshots.Count);
@@ -447,7 +433,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _); IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
Assert.Equal(2, snapshots.Count); Assert.Equal(2, snapshots.Count);
@@ -482,7 +469,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated); IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
@@ -512,7 +500,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated); IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
@@ -542,7 +531,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer(); FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
dispatcher.Dispose(); dispatcher.Dispose();
@@ -569,20 +558,12 @@ public sealed class AlarmDispatcherTests
/// <summary>Gets the last subscription reference.</summary> /// <summary>Gets the last subscription reference.</summary>
public string? LastSubscription { get; private set; } public string? LastSubscription { get; private set; }
/// <summary>Gets the GUID from the last acknowledge call.</summary> /// <summary>Gets or sets the ack target both resolve methods return.</summary>
public Guid LastAckGuid { get; private set; } public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
/// <summary>Gets the comment from the last acknowledge call.</summary> /// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
public string? LastAckComment { get; private set; } public Guid LastResolvedGuid { get; private set; }
/// <summary>Gets the operator name from the last acknowledge call.</summary> /// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
public string? LastAckOperatorName { get; private set; } public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
/// <summary>Gets the operator node from the last acknowledge call.</summary>
public string? LastAckOperatorNode { 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 the result collection for snapshot operations.</summary> /// <summary>Gets or sets the result collection for snapshot operations.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } = public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>(); Array.Empty<MxAlarmSnapshotRecord>();
@@ -607,37 +588,19 @@ public sealed class AlarmDispatcherTests
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid( public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
LastAckGuid = alarmGuid; LastResolvedGuid = alarmGuid;
LastAckComment = ackComment; return AckTarget;
LastAckOperatorName = ackOperatorName;
LastAckOperatorNode = ackOperatorNode;
LastAckOperatorDomain = ackOperatorDomain;
LastAckOperatorFullName = ackOperatorFullName;
return AcknowledgeReturn;
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
string ackOperatorDomain, string ackOperatorFullName)
{ {
LastAckByNameTuple = (alarmName, providerName, groupName); LastResolvedName = (alarmName, providerName, groupName);
LastAckOperatorName = ackOperatorName; return AckTarget;
return AcknowledgeReturn;
} }
/// <summary>Gets the last acknowledge-by-name tuple (alarm name, provider, group).</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary> /// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
public bool SnapshotTruncated { get; set; } public bool SnapshotTruncated { get; set; }
@@ -664,49 +627,22 @@ public sealed class AlarmDispatcherTests
} }
} }
private sealed class FakeManagedAckClient : IManagedAlarmAckClient private sealed class FakeAckWriter : IAlarmAckWriter
{ {
/// <summary>Gets the last subscription expression.</summary> public string? LastItemAddress { get; private set; }
public string? LastSubscription { get; private set; } public string? LastComment { get; private set; }
/// <summary>Gets the GUID from the last acknowledge call.</summary> public AlarmAckOperator? LastOperator { get; private set; }
public Guid LastAckGuid { get; private set; } public AlarmAckResult Result { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
/// <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; } public bool Disposed { get; private set; }
/// <inheritdoc /> public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
public void Subscribe(string subscription)
{ {
if (ThrowOnSubscribe) throw new InvalidOperationException("RegisterConsumer returned 5."); LastItemAddress = itemAddress;
LastSubscription = subscription; LastComment = comment;
LastOperator = ackOperator;
return Result;
} }
/// <inheritdoc /> public void Dispose() => Disposed = true;
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;
}
} }
} }
@@ -663,15 +663,13 @@ public sealed class MxAccessStaSessionTests
} }
/// <inheritdoc /> /// <inheritdoc />
public int Acknowledge(Guid alarmGuid, string comment, string operatorUser, public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
string operatorNode, string operatorDomain, string operatorFullName) => AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
=> 0;
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName(string alarmName, string providerName, string groupName, public AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName,
string comment, string operatorUser, string operatorNode, string comment, AlarmAckOperator ackOperator)
string operatorDomain, string operatorFullName) => AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
=> 0;
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive( public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
@@ -1,229 +0,0 @@
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}");
}
@@ -86,7 +86,12 @@ public sealed class AlarmsLiveSmokeTests
new WNWRAPCONSUMERLib.wwAlarmConsumerClass(), new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
maxAlarmsPerFetch: 1024); maxAlarmsPerFetch: 1024);
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); using AlarmAckWriter ackWriter = new AlarmAckWriter(
new MxAccessComObjectFactory(),
() => new ZB.MOM.WW.MxGateway.Worker.Sta.StaMessagePump().PumpPendingMessages(),
TimeSpan.FromSeconds(5),
AlarmAckWriter.DefaultClientName);
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, ackWriter);
Log("Constructed consumer + sink + dispatcher."); Log("Constructed consumer + sink + dispatcher.");
dispatcher.Subscribe(SubscriptionExpression); dispatcher.Subscribe(SubscriptionExpression);
@@ -139,26 +144,23 @@ public sealed class AlarmsLiveSmokeTests
$"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag."); $"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag.");
Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'"); Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'");
// Try the ack with real Windows identity. AVEVA's AlarmAckByName // The ack authenticates a Galaxy operator and writes <tag>.AckMsg.
// may reject synthetic operator strings; using the current process // Operator credentials come from the environment (never logged);
// identity gives the alarm-history a recognizable principal. // the user defaults to the current process identity.
string realUser = Environment.UserName; AlarmAckOperator ackOperator = new AlarmAckOperator
string realNode = Environment.MachineName; {
string realDomain = Environment.UserDomainName ?? string.Empty; UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName,
Log($"Ack identity: user='{realUser}' node='{realNode}' domain='{realDomain}'"); Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty,
};
Log($"Ack identity: {ackOperator}");
int rc = dispatcher.AcknowledgeByName( AlarmAckResult result = dispatcher.AcknowledgeByName(
alarmName: alarmName, alarmName, provider, group, "alarms-live-smoke ack", ackOperator);
providerName: provider, Log($"AcknowledgeByName -> outcome={result.Outcome} native={result.NativeStatus} diag='{result.Diagnostic}'");
groupName: group,
ackComment: "alarms-live-smoke ack",
ackOperatorName: realUser,
ackOperatorNode: realNode,
ackOperatorDomain: realDomain,
ackOperatorFullName: realUser);
Log($"AcknowledgeByName(real identity) -> rc={rc}");
Assert.Equal(0, rc); Assert.True(
result.Outcome == AlarmAckOutcome.Applied || result.Outcome == AlarmAckOutcome.Unconfirmed,
$"Ack outcome {result.Outcome}: {result.Diagnostic}");
// 4. Wait for the post-ack transition. With the alarm flipping every // 4. Wait for the post-ack transition. With the alarm flipping every
// 10s and the consumer polling every 500ms, the next state // 10s and the consumer polling every 500ms, the next state
@@ -76,7 +76,7 @@ public sealed class WorkerPipeSession
options, options,
() => Process.GetCurrentProcess().Id, () => Process.GetCurrentProcess().Id,
new WorkerPipeSessionOptions(), new WorkerPipeSessionOptions(),
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)), () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
logger, logger,
stream) stream)
{ {
@@ -98,7 +98,7 @@ public sealed class WorkerPipeSession
options, options,
processIdProvider, processIdProvider,
new WorkerPipeSessionOptions(), new WorkerPipeSessionOptions(),
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)), () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
logger: null) logger: null)
{ {
} }
@@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession
// parameterless CompleteStartupHandshakeAsync is used without a // parameterless CompleteStartupHandshakeAsync is used without a
// prior factory call. // prior factory call.
_runtimeSession ??= new MxAccessStaSession( _runtimeSession ??= new MxAccessStaSession(
(eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)); (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null));
IWorkerRuntimeSession session = _runtimeSession; IWorkerRuntimeSession session = _runtimeSession;
try try
{ {
@@ -1,231 +0,0 @@
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();
}
}
}
@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
@@ -39,10 +40,12 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
private readonly Action? threadAffinityCheck; private readonly Action? threadAffinityCheck;
private readonly IMxAccessComObjectFactory? comFactory; private readonly IMxAccessComObjectFactory? comFactory;
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory; private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
private readonly Func<IManagedAlarmAckClient?>? managedAckFactory; private readonly Func<IAlarmAckWriter>? ackWriterFactory;
private readonly MxAccessEventMapper mapper = new MxAccessEventMapper(); private readonly MxAccessEventMapper mapper = new MxAccessEventMapper();
private readonly object syncRoot = new object(); private readonly object syncRoot = new object();
private readonly IAlarmAckWriter deferredAckWriter;
private AlarmDispatcher? dispatcher; private AlarmDispatcher? dispatcher;
private IAlarmAckWriter? ackWriter;
private FailoverAlarmConsumer? failoverConsumer; private FailoverAlarmConsumer? failoverConsumer;
private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler; private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler;
private string subscribeSessionId = string.Empty; private string subscribeSessionId = string.Empty;
@@ -118,14 +121,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
/// <paramref name="comFactory"/>. Tests inject a fake so they need no /// <paramref name="comFactory"/>. Tests inject a fake so they need no
/// live COM factory. /// live COM factory.
/// </param> /// </param>
/// <param name="managedAckFactory"> /// <param name="ackWriterFactory">
/// Optional per-subscription factory for a managed ack client /// Optional seam that builds the <see cref="IAlarmAckWriter"/> used for
/// (<see cref="IManagedAlarmAckClient"/>). When it yields a non-null /// every acknowledgement. Defaults to an <see cref="AlarmAckWriter"/> over
/// client, GUID-keyed acknowledges route through AVEVA's modern /// <paramref name="comFactory"/> that registers its own MXAccess server
/// <c>AlarmClient.AlarmAckByGUID</c> instead of the wnwrap COM path. /// (client name from <see cref="AlarmAckWriter.ResolveClientName"/>).
/// Defaults to <see langword="null"/> (wnwrap ack). Production supplies /// The writer is created once per handler, on the first acknowledgement
/// <see cref="AaManagedAlarmAckConsumer.EnvGatedFactory"/>, which is a /// (so a subscription never fails because ack is unavailable), survives
/// no-op unless <c>MXGATEWAY_USE_MANAGED_ALARM_ACK=1</c>. /// unsubscribe/resubscribe, and is disposed with the handler. Tests
/// inject a fake.
/// </param> /// </param>
public AlarmCommandHandler( public AlarmCommandHandler(
MxAccessEventQueue eventQueue, MxAccessEventQueue eventQueue,
@@ -133,14 +137,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
Action? threadAffinityCheck, Action? threadAffinityCheck,
IMxAccessComObjectFactory? comFactory, IMxAccessComObjectFactory? comFactory,
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory, Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
Func<IManagedAlarmAckClient?>? managedAckFactory = null) Func<IAlarmAckWriter>? ackWriterFactory = null)
{ {
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory)); this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
this.threadAffinityCheck = threadAffinityCheck; this.threadAffinityCheck = threadAffinityCheck;
this.comFactory = comFactory; this.comFactory = comFactory;
this.standbyFactory = standbyFactory; this.standbyFactory = standbyFactory;
this.managedAckFactory = managedAckFactory; this.ackWriterFactory = ackWriterFactory;
this.deferredAckWriter = new DeferredAckWriter(this);
} }
/// <summary>Gets a value indicating whether the handler is subscribed.</summary> /// <summary>Gets a value indicating whether the handler is subscribed.</summary>
@@ -181,14 +186,8 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
failover.ProviderModeChanged += providerModeChangedHandler; 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); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper);
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, managedAck); dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, deferredAckWriter);
try try
{ {
dispatcher.Subscribe(subscriptionExpression); dispatcher.Subscribe(subscriptionExpression);
@@ -323,47 +322,31 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
} }
/// <inheritdoc /> /// <inheritdoc />
public int Acknowledge( public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
{ {
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
threadAffinityCheck?.Invoke(); threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow(); AlarmDispatcher d = GetDispatcherOrThrow();
return d.Acknowledge( return d.Acknowledge(alarmGuid, comment ?? string.Empty, ackOperator);
alarmGuid,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckResult AcknowledgeByName(
string alarmName, string alarmName,
string providerName, string providerName,
string groupName, string groupName,
string comment, string comment,
string operatorUser, AlarmAckOperator ackOperator)
string operatorNode,
string operatorDomain,
string operatorFullName)
{ {
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
threadAffinityCheck?.Invoke(); threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow(); AlarmDispatcher d = GetDispatcherOrThrow();
return d.AcknowledgeByName( return d.AcknowledgeByName(
alarmName ?? string.Empty, alarmName ?? string.Empty,
providerName ?? string.Empty, providerName ?? string.Empty,
groupName ?? string.Empty, groupName ?? string.Empty,
comment ?? string.Empty, comment ?? string.Empty,
operatorUser ?? string.Empty, ackOperator);
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -398,6 +381,60 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
d?.PollOnce(); d?.PollOnce();
} }
private IAlarmAckWriter GetOrCreateAckWriter()
{
lock (syncRoot)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (ackWriter is not null) return ackWriter;
if (ackWriterFactory is not null)
{
ackWriter = ackWriterFactory() ?? throw new InvalidOperationException("Alarm ack writer factory returned null.");
return ackWriter;
}
if (comFactory is null)
{
throw new InvalidOperationException(
"Alarm acknowledgement requires an IMxAccessComObjectFactory; the alarm command handler was constructed without one.");
}
// StaMessagePump.PumpPendingMessages is PeekMessage/DispatchMessage on
// the calling thread — the STA, since acks run there — which is what
// delivers the writer's OnWriteComplete callback.
ackWriter = new AlarmAckWriter(
comFactory,
() => new StaMessagePump().PumpPendingMessages(),
MxAccessStaSession.ResolveWriteCompletionTimeout(),
AlarmAckWriter.ResolveClientName());
return ackWriter;
}
}
/// <summary>
/// The writer handed to each <see cref="AlarmDispatcher"/>. Resolves the
/// handler's real writer on first acknowledgement so the writer (and its
/// MXAccess server registration) is created only when an ack happens and
/// outlives dispatcher churn. Disposing it is a no-op; the handler owns
/// the real writer.
/// </summary>
private sealed class DeferredAckWriter : IAlarmAckWriter
{
private readonly AlarmCommandHandler owner;
public DeferredAckWriter(AlarmCommandHandler owner)
{
this.owner = owner;
}
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) =>
owner.GetOrCreateAckWriter().Acknowledge(itemAddress, comment, ackOperator);
public void Dispose()
{
}
}
private AlarmDispatcher GetDispatcherOrThrow() private AlarmDispatcher GetDispatcherOrThrow()
{ {
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler)); if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
@@ -418,5 +455,13 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
Unsubscribe(); Unsubscribe();
IAlarmAckWriter? writerToDispose;
lock (syncRoot)
{
writerToDispose = ackWriter;
ackWriter = null;
}
try { writerToDispose?.Dispose(); } catch { /* swallow */ }
} }
} }
@@ -18,7 +18,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// worker's IPC layer issues <c>SubscribeAlarmsCommand</c> / /// worker's IPC layer issues <c>SubscribeAlarmsCommand</c> /
/// <c>AcknowledgeAlarmCommand</c> / <c>QueryActiveAlarmsCommand</c> /// <c>AcknowledgeAlarmCommand</c> / <c>QueryActiveAlarmsCommand</c>
/// through <see cref="AlarmCommandHandler"/>, which owns one /// through <see cref="AlarmCommandHandler"/>, which owns one
/// dispatcher per session. /// dispatcher per session. Acknowledgement resolves the alarm to its
/// MXAccess ack item through the consumer and hands the write to the
/// <see cref="IAlarmAckWriter"/>, which authenticates as the operator.
/// </para> /// </para>
/// <para> /// <para>
/// Threading: <see cref="WnWrapAlarmConsumer"/> owns no internal /// Threading: <see cref="WnWrapAlarmConsumer"/> owns no internal
@@ -36,30 +38,29 @@ public sealed class AlarmDispatcher : IDisposable
private readonly MxAccessAlarmEventSink sink; private readonly MxAccessAlarmEventSink sink;
private readonly string sessionId; private readonly string sessionId;
private readonly EventHandler<MxAlarmTransitionEvent> handler; private readonly EventHandler<MxAlarmTransitionEvent> handler;
private IManagedAlarmAckClient? managedAck; private readonly IAlarmAckWriter ackWriter;
private bool disposed; private bool disposed;
/// <summary>Initializes a new alarm dispatcher for the given consumer, sink, and session ID.</summary> /// <summary>Initializes a new alarm dispatcher for the given consumer, sink, session ID and ack writer.</summary>
/// <param name="consumer">The alarm consumer.</param> /// <param name="consumer">The alarm consumer.</param>
/// <param name="sink">The alarm event sink.</param> /// <param name="sink">The alarm event sink.</param>
/// <param name="sessionId">The session identifier.</param> /// <param name="sessionId">The session identifier.</param>
/// <param name="managedAck"> /// <param name="ackWriter">
/// Optional managed ack client. When non-null, GUID-keyed acknowledges /// Writes the resolved ack item as the authenticated operator. Owned by
/// route to it (AVEVA's modern <c>AlarmClient.AlarmAckByGUID</c>) instead /// the caller (<see cref="AlarmCommandHandler"/>), which disposes it; the
/// of the wnwrap consumer while the alarmmgr provider is active; null /// dispatcher never does, so the writer's registered MXAccess server
/// keeps the existing wnwrap ack path. Subtag-mode acks always stay on /// outlives an unsubscribe/resubscribe cycle.
/// the consumer because their GUIDs are synthetic.
/// </param> /// </param>
public AlarmDispatcher( public AlarmDispatcher(
IMxAccessAlarmConsumer consumer, IMxAccessAlarmConsumer consumer,
MxAccessAlarmEventSink sink, MxAccessAlarmEventSink sink,
string sessionId, string sessionId,
IManagedAlarmAckClient? managedAck = null) IAlarmAckWriter ackWriter)
{ {
this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer)); this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
this.sink = sink ?? throw new ArgumentNullException(nameof(sink)); this.sink = sink ?? throw new ArgumentNullException(nameof(sink));
this.sessionId = sessionId ?? string.Empty; this.sessionId = sessionId ?? string.Empty;
this.managedAck = managedAck; this.ackWriter = ackWriter ?? throw new ArgumentNullException(nameof(ackWriter));
// Sink.Attach is the seam that propagates the session id onto the // Sink.Attach is the seam that propagates the session id onto the
// proto SessionId field of every emitted MxEvent. Pass the consumer // proto SessionId field of every emitted MxEvent. Pass the consumer
// as the "associated COM object" — sink ignores the object reference // as the "associated COM object" — sink ignores the object reference
@@ -80,113 +81,37 @@ public sealed class AlarmDispatcher : IDisposable
{ {
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
consumer.Subscribe(subscription); 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> /// <summary>Acknowledges the alarm with the given GUID by writing its ack item as the operator.</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
/// consumer's <c>AlarmAckByGUID</c>. Returns the AVEVA-native
/// status code (0 = success).
/// </summary>
/// <param name="alarmGuid">The alarm GUID.</param> /// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="ackComment">The acknowledgment comment.</param> /// <param name="ackComment">The acknowledgement comment.</param>
/// <param name="ackOperatorName">The operator name.</param> /// <param name="ackOperator">The operator to authenticate as.</param>
/// <param name="ackOperatorNode">The operator node.</param> /// <returns>The outcome.</returns>
/// <param name="ackOperatorDomain">The operator domain.</param> public AlarmAckResult Acknowledge(Guid alarmGuid, string ackComment, AlarmAckOperator ackOperator)
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
public int Acknowledge(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
if (managedAck is not null && !IsSubtagActive()) AlarmAckTarget target = consumer.ResolveAckTarget(alarmGuid);
{ return target.Found
// Preferred path: AVEVA's modern AlarmClient.AlarmAckByGUID carries ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
// operator domain + full name and is not E_NOTIMPL like the wnwrap : AlarmAckResult.FromTarget(target);
// 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,
ackOperatorName,
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
} }
/// <summary> /// <summary>Acknowledges the alarm identified by name/provider/group as the operator.</summary>
/// Acknowledge an alarm by its (name, provider, group) tuple. /// <param name="alarmName">The tag/alarm name.</param>
/// Routes to the consumer's <c>AcknowledgeByName</c> path which /// <param name="providerName">The provider.</param>
/// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>. /// <param name="groupName">The group/area.</param>
/// </summary> /// <param name="ackComment">The acknowledgement comment.</param>
/// <param name="alarmName">The alarm name.</param> /// <param name="ackOperator">The operator to authenticate as.</param>
/// <param name="providerName">The provider name.</param> /// <returns>The outcome.</returns>
/// <param name="groupName">The group name.</param> public AlarmAckResult AcknowledgeByName(
/// <param name="ackComment">The acknowledgment comment.</param> string alarmName, string providerName, string groupName, string ackComment, AlarmAckOperator ackOperator)
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
public int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
return consumer.AcknowledgeByName( AlarmAckTarget target = consumer.ResolveAckTargetByName(alarmName, providerName, groupName);
alarmName, return target.Found
providerName, ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
groupName, : AlarmAckResult.FromTarget(target);
ackComment,
ackOperatorName,
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
} }
/// <summary> /// <summary>
@@ -327,6 +252,5 @@ public sealed class AlarmDispatcher : IDisposable
try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ } try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ }
try { sink.Detach(); } catch { /* swallow */ } try { sink.Detach(); } catch { /* swallow */ }
try { consumer.Dispose(); } catch { /* swallow */ } try { consumer.Dispose(); } catch { /* swallow */ }
try { managedAck?.Dispose(); } catch { /* swallow */ }
} }
} }
@@ -35,44 +35,21 @@ public interface IAlarmCommandHandler : IDisposable
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary> /// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
void Unsubscribe(); void Unsubscribe();
/// <summary>Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success).</summary> /// <summary>Acknowledges an alarm by GUID as <paramref name="ackOperator"/>.</summary>
/// <param name="alarmGuid">The alarm GUID.</param> /// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="comment">The acknowledgment comment.</param> /// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
/// <param name="operatorUser">The operator user name.</param> /// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
/// <param name="operatorNode">The operator node name.</param> /// <returns>The acknowledgement outcome.</returns>
/// <param name="operatorDomain">The operator domain name.</param> AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator);
/// <param name="operatorFullName">The operator full name.</param>
/// <returns>AVEVA's native alarm status code (0 = success).</returns>
int Acknowledge(
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName);
/// <summary> /// <summary>Acknowledges an alarm by name/provider/group as <paramref name="ackOperator"/>.</summary>
/// Acknowledge a single alarm by (name, provider, group) — used when /// <param name="alarmName">The alarm (tag) name.</param>
/// the caller has the human-readable reference but not the GUID.
/// </summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param> /// <param name="providerName">The provider name.</param>
/// <param name="groupName">The group name.</param> /// <param name="groupName">The group name.</param>
/// <param name="comment">The acknowledgment comment.</param> /// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
/// <param name="operatorUser">The operator user name.</param> /// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
/// <param name="operatorNode">The operator node name.</param> /// <returns>The acknowledgement outcome.</returns>
/// <param name="operatorDomain">The operator domain name.</param> AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator);
/// <param name="operatorFullName">The operator full name.</param>
/// <returns>AVEVA's native alarm status code (0 = success).</returns>
int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName);
/// <summary> /// <summary>
/// Snapshot the currently-active alarm set, optionally scoped to a /// Snapshot the currently-active alarm set, optionally scoped to a
@@ -1,44 +0,0 @@
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);
}
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion; using ZB.MOM.WW.MxGateway.Worker.Conversion;
using ZB.MOM.WW.MxGateway.Worker.Sta; using ZB.MOM.WW.MxGateway.Worker.Sta;
@@ -912,30 +913,25 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
$"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'."); $"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'.");
} }
// operator_password is a raw credential: it goes into AlarmAckOperator for
// MXAccess AuthenticateUser and nowhere else. Never format the payload
// (its generated ToString renders every field) or the exception text.
try try
{ {
int rc = alarmCommandHandler.Acknowledge( AlarmAckResult result = alarmCommandHandler.Acknowledge(
alarmGuid, alarmGuid,
payload.Comment, payload.Comment,
payload.OperatorUser, new AlarmAckOperator
payload.OperatorNode, {
payload.OperatorDomain, UserName = payload.OperatorUser,
payload.OperatorFullName); Domain = payload.OperatorDomain,
MxCommandReply reply = CreateOkReply(command); Password = payload.OperatorPassword,
reply.Hresult = rc; });
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload return CreateAcknowledgeReply(command, result);
{
NativeStatus = rc,
};
if (rc != 0)
{
reply.DiagnosticMessage = $"AVEVA AlarmAckByGUID returned non-zero status {rc}.";
}
return reply;
} }
catch (Exception ex) catch (Exception ex)
{ {
return CreateAlarmFailureReply(command, ex); return CreateAcknowledgeFailureReply(command, ex);
} }
} }
@@ -958,35 +954,80 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required."); return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required.");
} }
// See ExecuteAcknowledgeAlarm: operator_password never leaves AlarmAckOperator.
try try
{ {
int rc = alarmCommandHandler.AcknowledgeByName( AlarmAckResult result = alarmCommandHandler.AcknowledgeByName(
payload.AlarmName, payload.AlarmName,
payload.ProviderName, payload.ProviderName,
payload.GroupName, payload.GroupName,
payload.Comment, payload.Comment,
payload.OperatorUser, new AlarmAckOperator
payload.OperatorNode, {
payload.OperatorDomain, UserName = payload.OperatorUser,
payload.OperatorFullName); Domain = payload.OperatorDomain,
MxCommandReply reply = CreateOkReply(command); Password = payload.OperatorPassword,
reply.Hresult = rc; });
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload return CreateAcknowledgeReply(command, result);
{
NativeStatus = rc,
};
if (rc != 0)
{
reply.DiagnosticMessage = $"AVEVA AlarmAckByName returned non-zero status {rc}.";
}
return reply;
} }
catch (Exception ex) catch (Exception ex)
{ {
return CreateAlarmFailureReply(command, ex); return CreateAcknowledgeFailureReply(command, ex);
} }
} }
private static MxCommandReply CreateAcknowledgeReply(StaCommand command, AlarmAckResult result)
{
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = result.NativeStatus;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = result.NativeStatus };
reply.Statuses.Add(result.Statuses);
reply.DiagnosticMessage = result.Diagnostic;
switch (result.Outcome)
{
case AlarmAckOutcome.AlarmNotFound:
case AlarmAckOutcome.NotAckable:
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.InvalidRequest, Message = result.Diagnostic };
break;
case AlarmAckOutcome.AuthenticationFailed:
case AlarmAckOutcome.WriteRejected:
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure, Message = result.Diagnostic };
break;
}
return reply;
}
/// <summary>
/// Maps an exception escaping an acknowledgement (e.g. the ack writer's
/// MXAccess Register failing, or no active subscription) to an
/// <see cref="ProtocolStatusCode.MxaccessFailure"/> reply. Only the
/// exception type and HRESULT are reported: exception text on this path
/// is not under our control and must not be able to echo operator input.
/// </summary>
private static MxCommandReply CreateAcknowledgeFailureReply(StaCommand command, Exception exception)
{
string diagnostic = string.Format(
CultureInfo.InvariantCulture,
"Alarm acknowledgement failed: {0} (HRESULT 0x{1:X8}).",
exception.GetType().FullName,
exception.HResult);
return new MxCommandReply
{
SessionId = command.SessionId,
CorrelationId = command.CorrelationId,
Kind = command.Kind,
Hresult = exception.HResult,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.MxaccessFailure,
Message = diagnostic,
},
DiagnosticMessage = diagnostic,
AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = exception.HResult },
};
}
private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command) private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command)
{ {
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand) if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand)
@@ -1,273 +0,0 @@
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.
// ---------------------------------------------------------------------
}