Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02228b2717 | |||
| fb20997dc9 | |||
| 463748e395 | |||
| bd82036d5f | |||
| f13950e453 | |||
| 219611c821 | |||
| a09fa0a4d6 | |||
| 3e988ede88 | |||
| e7f56622c1 | |||
| 1998ee3e20 | |||
| 027cc7393d | |||
| 7b81cb7f72 | |||
| 0143591858 | |||
| e8ace39418 | |||
| 9907a97cb0 | |||
| 84e04b6552 | |||
| 417a47bdb5 | |||
| cfe9f817a3 | |||
| 9409488008 | |||
| 2858e42e80 |
@@ -126,7 +126,7 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
|
||||
- **One worker per session** (invariant). Multi-subscriber event fan-out and reconnect-with-replay have shipped and are config-gated: `AllowMultipleEventSubscribers` (default `false`) enables fan-out up to `MaxEventSubscribersPerSession` (default `8`); `DetachGraceSeconds` (default `30`) retains a session after its last subscriber drops so clients can reconnect; `ReplayBufferCapacity` / `ReplayRetentionSeconds` control how much event history the replay ring keeps. Default config is single-subscriber (`AllowMultipleEventSubscribers` off), but detach-grace and replay retention are **on** by default (`DetachGraceSeconds=30`, `ReplayBufferCapacity=1024`, `ReplayRetentionSeconds=300`): a detached session is retained for 30 s and recent events are buffered for reconnect. The reconnect protocol is consumable end-to-end: a resuming `StreamEvents` (via `after_worker_sequence`) that predates the retained ring gets a `ReplayGap` sentinel, and all five official clients surface it as a typed signal. Orphan-worker reattach after a gateway restart is **deferred, not planned** — see `docs/DesignDecisions.md` (Session-Resilience Epic Scope, session-resilience epic Phase 5); the invariant on the next line stands. See `docs/DesignDecisions.md` and `docs/Sessions.md`.
|
||||
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
|
||||
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
|
||||
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
|
||||
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, `AuthenticateUser` credentials, and `AcknowledgeAlarm` `operator_password` must never reach logs. Value logging is opt-in and redacted.
|
||||
- **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/zb_mom_ww_mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
|
||||
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
|
||||
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
|
||||
|
||||
@@ -143,8 +143,10 @@ the same `MxEventStreamItem` surface.
|
||||
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
|
||||
the active alarms the gateway's central monitor currently holds),
|
||||
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
|
||||
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
|
||||
reference, optional comment, ack target). All three accept a cancellation
|
||||
keyed by the same monitor), and `AcknowledgeAlarmAsync` (acknowledges as
|
||||
the operator: the gateway authenticates `OperatorUser`/`OperatorPassword` and
|
||||
writes the comment to the alarm's `AckMsg`, so the Historian records the
|
||||
operator). All three accept a cancellation
|
||||
token and pass through the `MxGateway:Alarms` configuration on the
|
||||
server — when alarms are disabled, the gateway returns an empty list / empty
|
||||
stream rather than failing.
|
||||
@@ -291,10 +293,15 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --se
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 4 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||
```
|
||||
|
||||
`acknowledge-alarm` reads the operator password from `--operator-password` or the
|
||||
environment variable named by `--operator-password-env` (default
|
||||
`MXGATEWAY_OPERATOR_PASSWORD`); the password is never echoed and is redacted from
|
||||
any error output. Prefer the environment variable to keep it off the command line.
|
||||
|
||||
`smoke` opens a session, registers a client, adds one item, advises it,
|
||||
optionally writes a value when `--type` and `--value` are supplied, reads a
|
||||
bounded event stream, and closes the session in a `finally` block. CLI error
|
||||
|
||||
@@ -185,6 +185,7 @@ public static class MxGatewayClientCli
|
||||
exception.Message,
|
||||
apiKey,
|
||||
TryResolveVerifyUserPassword(arguments),
|
||||
ResolveOperatorPassword(arguments),
|
||||
arguments.GetOptional("value"));
|
||||
|
||||
if (forceJsonErrors || arguments.HasFlag("json"))
|
||||
@@ -352,6 +353,12 @@ public static class MxGatewayClientCli
|
||||
/// </summary>
|
||||
private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Default environment variable holding the operator password for
|
||||
/// <c>acknowledge-alarm</c> when <c>--operator-password-env</c> is not given.
|
||||
/// </summary>
|
||||
private const string OperatorPasswordEnvironmentName = "MXGATEWAY_OPERATOR_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Legacy environment variable, still honoured as a deprecated fallback
|
||||
/// for one release so existing scripts keep working.
|
||||
@@ -378,6 +385,30 @@ public static class MxGatewayClientCli
|
||||
: environmentName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the <c>acknowledge-alarm</c> operator password: <c>--operator-password</c>,
|
||||
/// then the environment variable named by <c>--operator-password-env</c>
|
||||
/// (default <c>MXGATEWAY_OPERATOR_PASSWORD</c>). An empty value is treated as
|
||||
/// absent. The value is never echoed; it is also fed to the error redactor.
|
||||
/// </summary>
|
||||
private static string? ResolveOperatorPassword(CliArguments arguments)
|
||||
{
|
||||
string? password = arguments.GetOptional("operator-password");
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
return password;
|
||||
}
|
||||
|
||||
string environmentName = arguments.GetOptional("operator-password-env") ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(environmentName))
|
||||
{
|
||||
environmentName = OperatorPasswordEnvironmentName;
|
||||
}
|
||||
|
||||
password = Environment.GetEnvironmentVariable(environmentName);
|
||||
return string.IsNullOrEmpty(password) ? null : password;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective MXAccess verify-user credential in this
|
||||
/// order: <c>--password</c>, the deprecated <c>--verify-user-password</c>
|
||||
@@ -1535,6 +1566,8 @@ public static class MxGatewayClientCli
|
||||
AlarmFullReference = arguments.GetRequired("reference"),
|
||||
Comment = arguments.GetOptional("comment") ?? string.Empty,
|
||||
OperatorUser = arguments.GetOptional("operator") ?? string.Empty,
|
||||
OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty,
|
||||
OperatorPassword = ResolveOperatorPassword(arguments) ?? string.Empty,
|
||||
};
|
||||
|
||||
return WriteReplyAsync(
|
||||
@@ -2465,7 +2498,7 @@ public static class MxGatewayClientCli
|
||||
writer.WriteLine("mxgw-dotnet bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>] [--tag-start <n>] [--tag-prefix <s>] [--tag-attribute <s>] [--timeout-ms <n>] [--client-name <name>]");
|
||||
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
|
||||
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> --operator <user> [--operator-domain <dom>] [--operator-password <pw> | --operator-password-env <VAR>] [--comment <text>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
|
||||
|
||||
@@ -36,6 +36,8 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = "investigating",
|
||||
OperatorUser = "alice",
|
||||
OperatorPassword = "pw-secret",
|
||||
OperatorDomain = "CORP",
|
||||
});
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
@@ -45,9 +47,35 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Equal("Tank01.Level.HiHi", call.Request.AlarmFullReference);
|
||||
Assert.Equal("investigating", call.Request.Comment);
|
||||
Assert.Equal("alice", call.Request.OperatorUser);
|
||||
Assert.Equal("pw-secret", call.Request.OperatorPassword);
|
||||
Assert.Equal("CORP", call.Request.OperatorDomain);
|
||||
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync redacts the operator password from a failing call's message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_FailureMessage_DoesNotContainOperatorPassword()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
|
||||
// The real transport maps RpcException to MxGatewayException using the status
|
||||
// detail; the fake passes exceptions verbatim, so enqueue the mapped shape.
|
||||
transport.AcknowledgeAlarmExceptions.Enqueue(new MxGatewayException("boom pw-secret"));
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
|
||||
MxGatewayException ex = await Assert.ThrowsAsync<MxGatewayException>(
|
||||
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
OperatorUser = "alice",
|
||||
OperatorPassword = "pw-secret",
|
||||
}));
|
||||
|
||||
Assert.DoesNotContain("pw-secret", ex.Message);
|
||||
Assert.Contains("boom", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
@@ -763,6 +763,7 @@ public sealed class MxGatewayClientCliTests
|
||||
[Fact]
|
||||
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("MXGW_TEST_OPPW", "op-pw-secret");
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
@@ -786,6 +787,10 @@ public sealed class MxGatewayClientCliTests
|
||||
"ack from cli",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password-env",
|
||||
"MXGW_TEST_OPPW",
|
||||
"--json",
|
||||
],
|
||||
output,
|
||||
@@ -797,6 +802,9 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
||||
Assert.Equal("ack from cli", request.Comment);
|
||||
Assert.Equal("operator1", request.OperatorUser);
|
||||
Assert.Equal("CORP", request.OperatorDomain);
|
||||
Assert.Equal("op-pw-secret", request.OperatorPassword);
|
||||
Assert.DoesNotContain("op-pw-secret", output.ToString());
|
||||
Assert.Contains("ack-fixture", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
@@ -183,24 +183,32 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an active MXAccess alarm condition through the gateway. The
|
||||
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
|
||||
/// scope and forwards the acknowledge to the worker's MXAccess session;
|
||||
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
|
||||
/// Acknowledges an alarm as the operator. The gateway authenticates
|
||||
/// <see cref="AcknowledgeAlarmRequest.OperatorUser"/> with
|
||||
/// <see cref="AcknowledgeAlarmRequest.OperatorPassword"/> and writes the
|
||||
/// comment to the alarm's AckMsg attribute, so the alarm history records
|
||||
/// the operator. The password is redacted from any surfaced error.
|
||||
/// </summary>
|
||||
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
||||
/// <param name="request">The acknowledge request: alarm reference, comment, operator credentials.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ThrowIfDisposed();
|
||||
|
||||
return ExecuteSafeUnaryAsync(
|
||||
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
|
||||
cancellationToken);
|
||||
try
|
||||
{
|
||||
return await ExecuteSafeUnaryAsync(
|
||||
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0)
|
||||
{
|
||||
throw MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -140,7 +140,11 @@ sentinel typed and observable; it never synthesizes or swallows it.
|
||||
|
||||
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
|
||||
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
|
||||
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
|
||||
`Client.AcknowledgeAlarm` to ack an alarm by full reference. Acks carry the
|
||||
operator's Galaxy credentials (`OperatorUser` required, `OperatorPassword`,
|
||||
optional `OperatorDomain`) so the gateway authenticates as the operator and the
|
||||
Historian records that operator; an empty password is allowed, and the password
|
||||
is scrubbed from any returned error. The streaming
|
||||
call returns a `StreamAlarmsClient`; cancel its context to terminate the
|
||||
stream. All three pass straight through to the gateway's central alarm
|
||||
monitor.
|
||||
@@ -424,7 +428,7 @@ Every subcommand wired into the CLI. All accept the common flags
|
||||
| `bench-read-bulk` | Throughput benchmark (`-duration-seconds`, `-warmup-seconds`, `-bulk-size`). |
|
||||
| `stream-events` | Stream item-value events for a session (`-session-id`, `-limit`). |
|
||||
| `stream-alarms` | Stream the alarm feed (`-filter-prefix`, `-limit`). |
|
||||
| `acknowledge-alarm` | Acknowledge an alarm reference. |
|
||||
| `acknowledge-alarm` | Acknowledge an alarm as an operator (`-operator`, `-operator-domain`, `-operator-password-env`, default `MXGATEWAY_OPERATOR_PASSWORD`). |
|
||||
| `smoke` | End-to-end smoke workflow against one item. |
|
||||
| `galaxy-test-connection` | Probe the Galaxy Repository RPC connection. |
|
||||
| `galaxy-last-deploy` | Print the most recent deploy event. |
|
||||
|
||||
@@ -1119,6 +1119,10 @@ func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultOperatorPasswordEnv is the CLI environment variable holding the
|
||||
// operator password for acknowledge-alarm.
|
||||
const defaultOperatorPasswordEnv = "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
|
||||
func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -1126,7 +1130,12 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
reference := flags.String("reference", "", "full alarm reference to acknowledge")
|
||||
comment := flags.String("comment", "", "operator acknowledge comment")
|
||||
operator := flags.String("operator", "", "operator user performing the acknowledge")
|
||||
operator := flags.String("operator", "", "operator user performing the acknowledge (required)")
|
||||
operatorDomain := flags.String("operator-domain", "", "operator Galaxy domain (optional)")
|
||||
// Prefer the environment variable so the credential stays out of shell
|
||||
// history and the process table; an empty password is allowed.
|
||||
operatorPassword := flags.String("operator-password", "", "operator password (prefer -operator-password-env)")
|
||||
operatorPasswordEnv := flags.String("operator-password-env", defaultOperatorPasswordEnv, "environment variable containing the operator password")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -1134,6 +1143,17 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
if *reference == "" {
|
||||
return errors.New("reference is required")
|
||||
}
|
||||
if *operator == "" {
|
||||
return errors.New("operator is required")
|
||||
}
|
||||
resolvedOperatorPassword := *operatorPassword
|
||||
if resolvedOperatorPassword == "" {
|
||||
envName := *operatorPasswordEnv
|
||||
if envName == "" {
|
||||
envName = defaultOperatorPasswordEnv
|
||||
}
|
||||
resolvedOperatorPassword = os.Getenv(envName)
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
@@ -1145,6 +1165,8 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
AlarmFullReference: *reference,
|
||||
Comment: *comment,
|
||||
OperatorUser: *operator,
|
||||
OperatorDomain: *operatorDomain,
|
||||
OperatorPassword: resolvedOperatorPassword,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -3583,10 +3583,13 @@ func (x *AlarmFailoverConfig) GetFailbackStableProbes() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
||||
// recorded atomically with the ack transition in the alarm-history log.
|
||||
// The reply's hresult / native_status surfaces AVEVA's
|
||||
// AlarmAckByGUID return code.
|
||||
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
|
||||
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
|
||||
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
|
||||
// AuthenticateUser as the operator, so the alarm history records the real
|
||||
// operator. The reply's hresult / native_status is 0 when the write was
|
||||
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
|
||||
// the OnWriteComplete status detail.
|
||||
type AcknowledgeAlarmCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
|
||||
@@ -3596,6 +3599,10 @@ type AcknowledgeAlarmCommand struct {
|
||||
OperatorNode string `protobuf:"bytes,4,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"`
|
||||
OperatorDomain string `protobuf:"bytes,5,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
|
||||
OperatorFullName string `protobuf:"bytes,6,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"`
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
OperatorPassword string `protobuf:"bytes,7,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -3672,6 +3679,13 @@ func (x *AcknowledgeAlarmCommand) GetOperatorFullName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmCommand) GetOperatorPassword() string {
|
||||
if x != nil {
|
||||
return x.OperatorPassword
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Snapshot the currently-active alarm set. Optional filter prefix scopes
|
||||
// the snapshot to alarms whose alarm_full_reference starts with the
|
||||
// supplied string (matches QueryActiveAlarmsRequest.alarm_filter_prefix).
|
||||
@@ -3721,9 +3735,7 @@ func (x *QueryActiveAlarmsCommand) GetAlarmFilterPrefix() string {
|
||||
|
||||
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
|
||||
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
|
||||
// be resolved to a GUID directly. The worker invokes
|
||||
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
|
||||
// history path as AlarmAckByGUID.
|
||||
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
|
||||
type AcknowledgeAlarmByNameCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
|
||||
@@ -3739,6 +3751,10 @@ type AcknowledgeAlarmByNameCommand struct {
|
||||
OperatorNode string `protobuf:"bytes,6,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"`
|
||||
OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
|
||||
OperatorFullName string `protobuf:"bytes,8,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"`
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
OperatorPassword string `protobuf:"bytes,9,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -3829,6 +3845,13 @@ func (x *AcknowledgeAlarmByNameCommand) GetOperatorFullName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmByNameCommand) GetOperatorPassword() string {
|
||||
if x != nil {
|
||||
return x.OperatorPassword
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type UnsubscribeBulkCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||
@@ -6127,11 +6150,11 @@ func (x *DrainEventsReply) GetEvents() []*MxEvent {
|
||||
// Reply payload for AcknowledgeAlarmCommand AND
|
||||
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
|
||||
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
|
||||
// dedicated by-name reply case. Surfaces AVEVA's native ack return
|
||||
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
|
||||
// by-name arm); 0 means success. The MxCommandReply's hresult field
|
||||
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
|
||||
// AckMsg write was applied or sent but unconfirmed; otherwise the
|
||||
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
|
||||
// carries the same value and is preferred for protocol consumers —
|
||||
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
|
||||
// this payload exists so GatewayAlarmMonitor
|
||||
// can echo native_status into AcknowledgeAlarmReply.hresult without
|
||||
// unpacking the outer envelope.
|
||||
type AcknowledgeAlarmReplyPayload struct {
|
||||
@@ -7217,9 +7240,18 @@ type AcknowledgeAlarmRequest struct {
|
||||
Comment string `protobuf:"bytes,4,opt,name=comment,proto3" json:"comment,omitempty"`
|
||||
// Operator principal performing the acknowledgement. The lmxopcua side
|
||||
// resolves this from the OPC UA session prior to invoking the RPC.
|
||||
OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"`
|
||||
// Operator's Galaxy (or OS) password. The gateway authenticates as
|
||||
// operator_user with it before writing the acknowledgement so the alarm
|
||||
// history records the real operator. May be empty for accounts without a
|
||||
// password. Credential-sensitive: implementations must keep this field out
|
||||
// of logs, metrics labels, command lines, and diagnostics.
|
||||
OperatorPassword string `protobuf:"bytes,6,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
|
||||
// Optional domain for OS-authenticated galaxies. When set the gateway
|
||||
// authenticates as "<operator_domain>\<operator_user>".
|
||||
OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmRequest) Reset() {
|
||||
@@ -7280,14 +7312,27 @@ func (x *AcknowledgeAlarmRequest) GetOperatorUser() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmRequest) GetOperatorPassword() string {
|
||||
if x != nil {
|
||||
return x.OperatorPassword
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmRequest) GetOperatorDomain() string {
|
||||
if x != nil {
|
||||
return x.OperatorDomain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type AcknowledgeAlarmReply struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"`
|
||||
ProtocolStatus *ProtocolStatus `protobuf:"bytes,3,opt,name=protocol_status,json=protocolStatus,proto3" json:"protocol_status,omitempty"`
|
||||
// Native ack return code echoed from the worker. The worker carries the
|
||||
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
|
||||
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
|
||||
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
|
||||
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
|
||||
// ack-outcome field for the public RPC. Absent only when the worker reply
|
||||
// omitted the value entirely (a protocol violation).
|
||||
Hresult *int32 `protobuf:"varint,4,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"`
|
||||
@@ -8992,7 +9037,7 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"\x13AlarmFailoverConfig\x12B\n" +
|
||||
"\x1dconsecutive_failure_threshold\x18\x01 \x01(\x05R\x1bconsecutiveFailureThreshold\x12E\n" +
|
||||
"\x1ffailback_probe_interval_seconds\x18\x02 \x01(\x05R\x1cfailbackProbeIntervalSeconds\x124\n" +
|
||||
"\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xf3\x01\n" +
|
||||
"\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xa0\x02\n" +
|
||||
"\x17AcknowledgeAlarmCommand\x12\x1d\n" +
|
||||
"\n" +
|
||||
"alarm_guid\x18\x01 \x01(\tR\talarmGuid\x12\x18\n" +
|
||||
@@ -9000,9 +9045,10 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"\roperator_user\x18\x03 \x01(\tR\foperatorUser\x12#\n" +
|
||||
"\roperator_node\x18\x04 \x01(\tR\foperatorNode\x12'\n" +
|
||||
"\x0foperator_domain\x18\x05 \x01(\tR\x0eoperatorDomain\x12,\n" +
|
||||
"\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\"J\n" +
|
||||
"\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\x12+\n" +
|
||||
"\x11operator_password\x18\a \x01(\tR\x10operatorPassword\"J\n" +
|
||||
"\x18QueryActiveAlarmsCommand\x12.\n" +
|
||||
"\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xbd\x02\n" +
|
||||
"\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xea\x02\n" +
|
||||
"\x1dAcknowledgeAlarmByNameCommand\x12\x1d\n" +
|
||||
"\n" +
|
||||
"alarm_name\x18\x01 \x01(\tR\talarmName\x12#\n" +
|
||||
@@ -9013,7 +9059,8 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12#\n" +
|
||||
"\roperator_node\x18\x06 \x01(\tR\foperatorNode\x12'\n" +
|
||||
"\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomain\x12,\n" +
|
||||
"\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\"`\n" +
|
||||
"\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\x12+\n" +
|
||||
"\x11operator_password\x18\t \x01(\tR\x10operatorPassword\"`\n" +
|
||||
"\x16UnsubscribeBulkCommand\x12#\n" +
|
||||
"\rserver_handle\x18\x01 \x01(\x05R\fserverHandle\x12!\n" +
|
||||
"\fitem_handles\x18\x02 \x03(\x05R\vitemHandles\"v\n" +
|
||||
@@ -9262,12 +9309,14 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"limitValue\x12\x1a\n" +
|
||||
"\bdegraded\x18\x0e \x01(\bR\bdegraded\x12O\n" +
|
||||
"\x0fsource_provider\x18\x0f \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x0esourceProvider\x126\n" +
|
||||
"\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xd0\x01\n" +
|
||||
"\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xa6\x02\n" +
|
||||
"\x17AcknowledgeAlarmRequest\x122\n" +
|
||||
"\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" +
|
||||
"\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" +
|
||||
"\acomment\x18\x04 \x01(\tR\acomment\x12#\n" +
|
||||
"\roperator_user\x18\x05 \x01(\tR\foperatorUserJ\x04\b\x01\x10\x02R\n" +
|
||||
"\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12+\n" +
|
||||
"\x11operator_password\x18\x06 \x01(\tR\x10operatorPassword\x12'\n" +
|
||||
"\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomainJ\x04\b\x01\x10\x02R\n" +
|
||||
"session_id\"\xb4\x02\n" +
|
||||
"\x15AcknowledgeAlarmReply\x12%\n" +
|
||||
"\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x12L\n" +
|
||||
|
||||
@@ -10,6 +10,11 @@ import (
|
||||
// invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess
|
||||
// session; the resulting native MxStatus is returned in the reply.
|
||||
//
|
||||
// OperatorUser is required: the gateway authenticates the operator (with
|
||||
// OperatorPassword and optional OperatorDomain) so the Historian records the
|
||||
// real operator. An empty password is allowed. The password is scrubbed from
|
||||
// any error this method returns.
|
||||
//
|
||||
// Acks are idempotent — re-acking an already-acked condition is a no-op at
|
||||
// the MxAccess layer.
|
||||
func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
||||
@@ -22,10 +27,10 @@ func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequ
|
||||
|
||||
reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "acknowledge alarm", Err: err}
|
||||
return nil, redactSecrets(&GatewayError{Op: "acknowledge alarm", Err: err}, req.GetOperatorPassword())
|
||||
}
|
||||
if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
|
||||
return reply, err
|
||||
return reply, redactSecrets(err, req.GetOperatorPassword())
|
||||
}
|
||||
|
||||
return reply, nil
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
@@ -38,6 +39,8 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
Comment: "investigating",
|
||||
OperatorUser: "alice",
|
||||
OperatorPassword: "pw-secret",
|
||||
OperatorDomain: "CORP",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AcknowledgeAlarm() error = %v", err)
|
||||
@@ -51,11 +54,37 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||
if got := fake.acknowledgeRequest.GetComment(); got != "investigating" {
|
||||
t.Fatalf("captured comment = %q", got)
|
||||
}
|
||||
if got := fake.acknowledgeRequest.GetOperatorPassword(); got != "pw-secret" {
|
||||
t.Fatalf("captured operator password mismatch")
|
||||
}
|
||||
if got := fake.acknowledgeRequest.GetOperatorDomain(); got != "CORP" {
|
||||
t.Fatalf("captured operator domain = %q", got)
|
||||
}
|
||||
if got := fake.acknowledgeAuth; got != "Bearer test-api-key" {
|
||||
t.Fatalf("authorization metadata = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeAlarmRedactsPasswordFromError(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{
|
||||
acknowledgeError: status.Error(codes.Internal, "boom pw-secret"),
|
||||
}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
OperatorUser: "alice",
|
||||
OperatorPassword: "pw-secret",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("AcknowledgeAlarm() returned no error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "pw-secret") {
|
||||
t.Fatalf("error leaks operator password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
|
||||
@@ -114,8 +114,11 @@ try (MxEventStream events = session.streamEventsAfter(lastSeenSequence)) {
|
||||
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
|
||||
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
|
||||
yields alarm-feed messages from the gateway's central monitor), and
|
||||
`acknowledgeAlarm` (ack by full alarm reference with an optional comment and
|
||||
ack target). Close the subscription to cancel the underlying gRPC stream.
|
||||
`acknowledgeAlarm` (ack by full alarm reference with an optional comment).
|
||||
The ack carries operator credentials: `operator_user` is required, and
|
||||
`operator_password` / `operator_domain` let the gateway authenticate as that
|
||||
operator so the Historian records the real operator. An empty password is
|
||||
allowed. The password is scrubbed from exceptions the client throws. Close the subscription to cancel the underlying gRPC stream.
|
||||
|
||||
`ActiveAlarmSnapshot.getFromTruncatedSnapshot()` reports that the record came
|
||||
from a provider fetch which hit the per-fetch cap: the snapshot set may omit
|
||||
@@ -417,7 +420,7 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="authenticate-user --endpoint localho
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="write-secured --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --current-user-id 100 --verifier-user-id 100 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 4 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
|
||||
```
|
||||
|
||||
|
||||
+1261
-447
File diff suppressed because it is too large
Load Diff
+27
@@ -1822,6 +1822,31 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
@Option(names = "--operator", description = "Operator user performing the acknowledge.")
|
||||
String operator = "";
|
||||
|
||||
@Option(names = "--operator-domain", description = "Operator Galaxy domain (optional).")
|
||||
String operatorDomain = "";
|
||||
|
||||
@Option(
|
||||
names = "--operator-password",
|
||||
description = "Operator Galaxy password (credential; prefer --operator-password-env). Never echoed.")
|
||||
String operatorPassword;
|
||||
|
||||
@Option(
|
||||
names = "--operator-password-env",
|
||||
defaultValue = "MXGATEWAY_OPERATOR_PASSWORD",
|
||||
description = "Environment variable holding the operator password when --operator-password is omitted.")
|
||||
String operatorPasswordEnv;
|
||||
|
||||
private String resolveOperatorPassword() {
|
||||
if (operatorPassword != null && !operatorPassword.isEmpty()) {
|
||||
return operatorPassword;
|
||||
}
|
||||
String name = operatorPasswordEnv == null || operatorPasswordEnv.isBlank()
|
||||
? "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
: operatorPasswordEnv;
|
||||
String value = System.getenv(name);
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
AcknowledgeAlarmCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
@@ -1833,6 +1858,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
.setAlarmFullReference(reference)
|
||||
.setComment(comment)
|
||||
.setOperatorUser(operator)
|
||||
.setOperatorDomain(operatorDomain == null ? "" : operatorDomain)
|
||||
.setOperatorPassword(resolveOperatorPassword())
|
||||
.build());
|
||||
writeOutput(
|
||||
"acknowledge-alarm",
|
||||
|
||||
+11
@@ -714,9 +714,16 @@ final class MxGatewayCliTests {
|
||||
"checked",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password",
|
||||
"pw-secret",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals("CORP", factory.client.lastAcknowledgeAlarmRequest.getOperatorDomain());
|
||||
assertEquals("pw-secret", factory.client.lastAcknowledgeAlarmRequest.getOperatorPassword());
|
||||
assertFalse(run.output().contains("pw-secret"), run.output());
|
||||
assertEquals("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
|
||||
assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||
assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
|
||||
@@ -770,6 +777,10 @@ final class MxGatewayCliTests {
|
||||
"--plaintext",
|
||||
"--reference",
|
||||
"\\Galaxy\\Area001.Pump001.PumpFault",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-password-env",
|
||||
"MXGATEWAY_OPERATOR_PASSWORD",
|
||||
"--json"
|
||||
};
|
||||
assertReadmeExampleParses(args);
|
||||
|
||||
+27
-4
@@ -273,6 +273,11 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* worker's MXAccess session; the resulting native MxStatus is returned
|
||||
* in the reply. Acks are idempotent at the MxAccess layer.
|
||||
*
|
||||
* <p>{@code operator_user} is required; the gateway authenticates it with
|
||||
* {@code operator_password} (and optional {@code operator_domain}) and the
|
||||
* Historian records that operator. An empty password is allowed. The
|
||||
* password is scrubbed from any exception this method throws.
|
||||
*
|
||||
* @param request the {@code AcknowledgeAlarmRequest}
|
||||
* @return the raw acknowledge reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
@@ -283,10 +288,14 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
} catch (RuntimeException error) {
|
||||
if (error instanceof MxGatewayException) {
|
||||
throw error;
|
||||
MxGatewayException mapped = error instanceof MxGatewayException gatewayError
|
||||
? gatewayError
|
||||
: (MxGatewayException) MxGatewayErrors.fromGrpc("acknowledge alarm", error);
|
||||
String secret = request.getOperatorPassword();
|
||||
if (secret.isEmpty()) {
|
||||
throw mapped;
|
||||
}
|
||||
throw MxGatewayErrors.fromGrpc("acknowledge alarm", error);
|
||||
throw MxGatewayErrors.redacted(mapped, secret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,10 +308,24 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
*/
|
||||
public CompletableFuture<AcknowledgeAlarmReply> acknowledgeAlarmAsync(AcknowledgeAlarmRequest request) {
|
||||
CompletableFuture<AcknowledgeAlarmReply> future = toCompletable(rawFutureStub().acknowledgeAlarm(request));
|
||||
return future.thenApply(reply -> {
|
||||
String secret = request.getOperatorPassword();
|
||||
CompletableFuture<AcknowledgeAlarmReply> checked = future.thenApply(reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
});
|
||||
if (secret.isEmpty()) {
|
||||
return checked;
|
||||
}
|
||||
// Scrub the operator password from any failure surfaced through the future.
|
||||
return checked.exceptionally(error -> {
|
||||
Throwable cause = error instanceof java.util.concurrent.CompletionException && error.getCause() != null
|
||||
? error.getCause()
|
||||
: error;
|
||||
if (cause instanceof MxGatewayException gatewayError) {
|
||||
throw new java.util.concurrent.CompletionException(MxGatewayErrors.redacted(gatewayError, secret));
|
||||
}
|
||||
throw new java.util.concurrent.CompletionException(cause);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+37
@@ -28,6 +28,43 @@ final class MxGatewayErrors {
|
||||
return new MxGatewayException("gateway " + operation + " failed: " + error.getMessage(), error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds a gateway exception with an exact secret scrubbed from its
|
||||
* message and protocol status. The original is not chained as a cause
|
||||
* because its message and cause chain may carry the secret.
|
||||
*/
|
||||
static MxGatewayException redacted(MxGatewayException error, String secret) {
|
||||
String message = MxGatewaySecrets.redactExact(error.getMessage(), secret);
|
||||
if (error instanceof MxAccessException ex) {
|
||||
return new MxAccessException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null);
|
||||
}
|
||||
if (error instanceof MxGatewayCommandException ex) {
|
||||
return new MxGatewayCommandException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null);
|
||||
}
|
||||
if (error instanceof MxGatewaySessionException ex) {
|
||||
return new MxGatewaySessionException(message, scrub(ex.protocolStatus(), secret), null);
|
||||
}
|
||||
if (error instanceof MxGatewayWorkerException ex) {
|
||||
return new MxGatewayWorkerException(message, scrub(ex.protocolStatus(), secret), null);
|
||||
}
|
||||
if (error instanceof MxGatewayAuthenticationException) {
|
||||
return new MxGatewayAuthenticationException(message, null);
|
||||
}
|
||||
if (error instanceof MxGatewayAuthorizationException) {
|
||||
return new MxGatewayAuthorizationException(message, null);
|
||||
}
|
||||
return new MxGatewayException(message);
|
||||
}
|
||||
|
||||
private static ProtocolStatus scrub(ProtocolStatus status, String secret) {
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
return status.toBuilder()
|
||||
.setMessage(MxGatewaySecrets.redactExact(status.getMessage(), secret))
|
||||
.build();
|
||||
}
|
||||
|
||||
static void ensureProtocolSuccess(String operation, ProtocolStatus status, MxCommandReply reply) {
|
||||
if (status == null || status.getCode() == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) {
|
||||
return;
|
||||
|
||||
+46
@@ -74,6 +74,52 @@ final class MxGatewayCredentialReplyTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmAsyncRedactsOperatorPasswordFromFailure() throws Exception {
|
||||
String serverName = "mxgw-java-ack-" + UUID.randomUUID();
|
||||
MxAccessGatewayGrpc.MxAccessGatewayImplBase service = new MxAccessGatewayGrpc.MxAccessGatewayImplBase() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request,
|
||||
StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> observer) {
|
||||
observer.onNext(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.newBuilder()
|
||||
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
|
||||
.setMessage("auth failed for operator1 password " + CREDENTIAL))
|
||||
.build());
|
||||
observer.onCompleted();
|
||||
}
|
||||
};
|
||||
Server server = InProcessServerBuilder.forName(serverName)
|
||||
.directExecutor().addService(service).build().start();
|
||||
ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build();
|
||||
try (MxGatewayClient client = new MxGatewayClient(
|
||||
channel,
|
||||
MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process").apiKey("").plaintext(true)
|
||||
.callTimeout(Duration.ofSeconds(5)).build())) {
|
||||
var request = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.newBuilder()
|
||||
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||
.setOperatorUser("operator1")
|
||||
.setOperatorPassword(CREDENTIAL)
|
||||
.build();
|
||||
|
||||
java.util.concurrent.ExecutionException async = assertThrows(
|
||||
java.util.concurrent.ExecutionException.class,
|
||||
() -> client.acknowledgeAlarmAsync(request).get());
|
||||
assertTrue(async.getCause() instanceof MxGatewayException, String.valueOf(async.getCause()));
|
||||
assertFalse(async.getCause().getMessage().contains(CREDENTIAL));
|
||||
assertFalse(((MxGatewayCommandException) async.getCause()).protocolStatus().getMessage()
|
||||
.contains(CREDENTIAL));
|
||||
|
||||
MxGatewayException sync = assertThrows(MxGatewayException.class, () -> client.acknowledgeAlarm(request));
|
||||
assertFalse(sync.getMessage().contains(CREDENTIAL));
|
||||
} finally {
|
||||
channel.shutdownNow();
|
||||
server.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticateUserMissingPayloadThrowsMalformedReply() throws Exception {
|
||||
MxCommandReply reply = loadReply("authenticate-user.missing-payload.reply.json");
|
||||
|
||||
@@ -111,9 +111,15 @@ MXAccess commands and preserve raw replies on typed command exceptions.
|
||||
For alarms, the client exposes `GatewayClient.query_active_alarms` (one-shot
|
||||
snapshot), `GatewayClient.stream_alarms` (async generator yielding alarm-feed
|
||||
messages from the gateway's central monitor), and
|
||||
`GatewayClient.acknowledge_alarm` (ack by alarm reference, optional comment
|
||||
and ack target). Cancel the surrounding task or `aclose()` the iterator to
|
||||
terminate the stream.
|
||||
`GatewayClient.acknowledge_alarm` (ack by alarm reference with an optional
|
||||
comment). The ack is made as the operator: set `operator_user` (required) and
|
||||
`operator_password` (may be empty) and optionally `operator_domain` on
|
||||
`AcknowledgeAlarmRequest`. The gateway authenticates the operator and writes the
|
||||
comment to the alarm's `AckMsg`, so the alarm history records the operator. The
|
||||
password is redacted from any raised error.
|
||||
|
||||
Cancel the surrounding task or `aclose()` an alarm iterator to terminate the
|
||||
stream.
|
||||
|
||||
`ActiveAlarmSnapshot.from_truncated_snapshot` reports that the record came from
|
||||
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
||||
@@ -457,7 +463,7 @@ mxgw-py add-item --session-id <id> --server-handle 1 --item Object.Attribute --j
|
||||
mxgw-py advise --session-id <id> --server-handle 1 --item-handle 2 --json
|
||||
mxgw-py stream-events --session-id <id> --max-events 1 --json
|
||||
mxgw-py stream-alarms --max-messages 4 --json
|
||||
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json
|
||||
mxgw-py write --session-id <id> --server-handle 1 --item-handle 2 --type int32 --value 123 --json
|
||||
```
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
import grpc
|
||||
|
||||
from .auth import merge_metadata
|
||||
from .errors import ensure_protocol_success, map_rpc_error
|
||||
from .errors import MxGatewayError, ensure_protocol_success, map_rpc_error
|
||||
from .generated import mxaccess_gateway_pb2 as pb
|
||||
from .generated import mxaccess_gateway_pb2_grpc as pb_grpc
|
||||
from .options import ClientOptions, create_channel, resolve_channel_security
|
||||
@@ -148,17 +148,23 @@ class GatewayClient:
|
||||
self,
|
||||
request: pb.AcknowledgeAlarmRequest,
|
||||
) -> pb.AcknowledgeAlarmReply:
|
||||
"""Acknowledge an active MXAccess alarm condition through the gateway.
|
||||
"""Acknowledge an alarm as the operator.
|
||||
|
||||
The gateway authenticates the request against the API key's
|
||||
``invoke:alarm-ack`` scope and forwards the acknowledge to the worker's
|
||||
MXAccess session; the resulting native ``MxStatus`` is returned in the
|
||||
reply. Acks are idempotent — re-acking an already-acked condition is a
|
||||
no-op at the MxAccess layer.
|
||||
The gateway authenticates ``operator_user`` with ``operator_password`` and
|
||||
writes the comment to the alarm's ``AckMsg``, so the alarm history records
|
||||
the operator. The password is redacted from any raised error.
|
||||
"""
|
||||
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
||||
return reply
|
||||
try:
|
||||
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
||||
return reply
|
||||
except MxGatewayError as error:
|
||||
if request.operator_password:
|
||||
# Imported lazily: session.py imports this module at its tail.
|
||||
from .session import _redact_error
|
||||
|
||||
_redact_error(error, [request.operator_password])
|
||||
raise
|
||||
|
||||
def query_active_alarms(
|
||||
self,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import Any
|
||||
|
||||
from .auth import redact_secret
|
||||
from .errors import MalformedReplyError, MxGatewayError, ensure_mxaccess_success
|
||||
@@ -943,22 +944,26 @@ def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
|
||||
error.raw_reply = _redact_reply(error.raw_reply, scrubbed)
|
||||
|
||||
|
||||
def _redact_reply(reply: pb.MxCommandReply, secrets: Sequence[str]) -> pb.MxCommandReply:
|
||||
def _redact_reply(reply: Any, secrets: Sequence[str]) -> Any:
|
||||
"""Return a deep copy of *reply* with credential text scrubbed from diagnostics.
|
||||
|
||||
Operates on a clone so the caller's original reply object is never mutated.
|
||||
Only the free-text diagnostic fields that can echo a client-supplied secret
|
||||
are scrubbed; the structured/enum fields the gateway itself sets are left as-is.
|
||||
Works for any reply message (``MxCommandReply``, ``AcknowledgeAlarmReply``, ...):
|
||||
each field is scrubbed only when the message type declares it.
|
||||
"""
|
||||
clone = type(reply)()
|
||||
clone.CopyFrom(reply)
|
||||
if clone.protocol_status.message:
|
||||
fields = clone.DESCRIPTOR.fields_by_name
|
||||
if "protocol_status" in fields and clone.protocol_status.message:
|
||||
clone.protocol_status.message = redact_secret(clone.protocol_status.message, secrets)
|
||||
if clone.diagnostic_message:
|
||||
if "diagnostic_message" in fields and clone.diagnostic_message:
|
||||
clone.diagnostic_message = redact_secret(clone.diagnostic_message, secrets)
|
||||
for status in clone.statuses:
|
||||
if status.diagnostic_text:
|
||||
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
|
||||
if "statuses" in fields:
|
||||
for status in clone.statuses:
|
||||
if status.diagnostic_text:
|
||||
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
|
||||
return clone
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ MAX_AGGREGATE_EVENTS = 10_000
|
||||
#: CLI (CLI-45) so one exported variable drives the same operator workflow in all
|
||||
#: five languages.
|
||||
DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD"
|
||||
DEFAULT_OPERATOR_PASSWORD_ENV = "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
|
||||
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
||||
|
||||
@@ -528,16 +529,30 @@ def stream_alarms(**kwargs: Any) -> None:
|
||||
@gateway_options
|
||||
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
|
||||
@click.option("--comment", default="", help="Acknowledgement comment.")
|
||||
@click.option("--operator", default="", help="Operator user name.")
|
||||
@click.option("--operator", required=True, help="Operator user name (recorded in the alarm history).")
|
||||
@click.option("--operator-domain", default="", help="Operator Windows/Galaxy domain (optional).")
|
||||
@click.option(
|
||||
"--operator-password",
|
||||
default=None,
|
||||
help="Operator password. Prefer --operator-password-env so the secret is not visible on the command line.",
|
||||
)
|
||||
@click.option(
|
||||
"--operator-password-env",
|
||||
default=DEFAULT_OPERATOR_PASSWORD_ENV,
|
||||
show_default=True,
|
||||
help="Environment variable holding the operator password (an unset variable means an empty password).",
|
||||
)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def acknowledge_alarm(**kwargs: Any) -> None:
|
||||
"""Acknowledge an active MXAccess alarm condition (session-less)."""
|
||||
"""Acknowledge an active MXAccess alarm condition as the operator (session-less)."""
|
||||
|
||||
password = _resolve_operator_password(kwargs)
|
||||
kwargs["operator_password"] = password
|
||||
_run(
|
||||
_acknowledge_alarm(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
secrets=_secrets(kwargs) + [password],
|
||||
)
|
||||
|
||||
|
||||
@@ -836,6 +851,21 @@ async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"userId": user_id}
|
||||
|
||||
|
||||
def _resolve_operator_password(kwargs: dict[str, Any]) -> str:
|
||||
"""Resolve the alarm-ack operator password from --operator-password or its env var.
|
||||
|
||||
Unlike :func:`_resolve_password`, an unset or empty value is allowed: Galaxy
|
||||
accounts may have empty passwords. The value is never echoed; callers add it
|
||||
to the ``secrets`` redaction list.
|
||||
"""
|
||||
|
||||
env_name = kwargs.get("operator_password_env") or DEFAULT_OPERATOR_PASSWORD_ENV
|
||||
password = kwargs.get("operator_password")
|
||||
if password is None:
|
||||
password = os.environ.get(env_name, "")
|
||||
return password
|
||||
|
||||
|
||||
def _resolve_password(kwargs: dict[str, Any]) -> str:
|
||||
"""Resolve the authenticate-user password from --password or --password-env.
|
||||
|
||||
@@ -1161,6 +1191,8 @@ async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
|
||||
alarm_full_reference=kwargs["reference"],
|
||||
comment=kwargs["comment"],
|
||||
operator_user=kwargs["operator"],
|
||||
operator_domain=kwargs["operator_domain"],
|
||||
operator_password=kwargs["operator_password"],
|
||||
),
|
||||
)
|
||||
return _message_dict(reply)
|
||||
|
||||
@@ -34,6 +34,8 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
comment="investigating",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
operator_domain="CORP",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -44,9 +46,68 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
|
||||
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert captured_request.comment == "investigating"
|
||||
assert captured_request.operator_user == "alice"
|
||||
assert captured_request.operator_password == "pw-secret"
|
||||
assert captured_request.operator_domain == "CORP"
|
||||
assert stub.acknowledge_alarm.metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_error_redacts_operator_password() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
stub.acknowledge_alarm.exception = FakeRpcError(
|
||||
grpc.StatusCode.UNAUTHENTICATED, "bad credentials pw-secret",
|
||||
)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(MxGatewayAuthenticationError) as excinfo:
|
||||
await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
),
|
||||
)
|
||||
|
||||
assert "pw-secret" not in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_protocol_failure_redacts_operator_password() -> None:
|
||||
from zb_mom_ww_mxgateway.errors import MxGatewayError
|
||||
|
||||
stub = FakeGatewayStub()
|
||||
stub.acknowledge_alarm.replies = [
|
||||
pb.AcknowledgeAlarmReply(
|
||||
protocol_status=pb.ProtocolStatus(
|
||||
code=pb.PROTOCOL_STATUS_CODE_INVALID_REQUEST,
|
||||
message="rejected credential pw-secret",
|
||||
),
|
||||
),
|
||||
]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(MxGatewayError) as excinfo:
|
||||
await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
),
|
||||
)
|
||||
|
||||
assert "pw-secret" not in str(excinfo.value)
|
||||
raw = excinfo.value.raw_reply
|
||||
assert isinstance(raw, pb.AcknowledgeAlarmReply)
|
||||
assert "pw-secret" not in raw.protocol_status.message
|
||||
assert "pw-secret" not in str(raw)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
|
||||
@@ -771,6 +771,7 @@ def test_cli_stream_alarms_positive_max_messages_remains_finite(monkeypatch) ->
|
||||
|
||||
|
||||
def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
|
||||
monkeypatch.setenv("MXGW_TEST_OPPW", "pw-secret")
|
||||
stub = _BulkFakeStub()
|
||||
stub.acknowledge_alarm = _BulkFakeUnary(
|
||||
[
|
||||
@@ -798,12 +799,19 @@ def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
|
||||
"investigating",
|
||||
"--operator",
|
||||
"alice",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password-env",
|
||||
"MXGW_TEST_OPPW",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "pw-secret" not in result.output
|
||||
captured_request = stub.acknowledge_alarm.requests[0]
|
||||
assert captured_request.operator_domain == "CORP"
|
||||
assert captured_request.operator_password == "pw-secret"
|
||||
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert captured_request.comment == "investigating"
|
||||
assert captured_request.operator_user == "alice"
|
||||
|
||||
@@ -78,7 +78,8 @@ cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --
|
||||
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
|
||||
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
|
||||
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 4 --json
|
||||
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
cargo run -p mxgw-cli -- acknowledge-alarm --reference "Area001.Pump001.PumpFault" --operator <user> [--operator-domain <domain>] --json
|
||||
# operator password: MXGATEWAY_OPERATOR_PASSWORD, or --operator-password-env / --operator-password
|
||||
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json
|
||||
```
|
||||
|
||||
@@ -124,6 +125,9 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer
|
||||
`stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms`
|
||||
returns an `AlarmFeedStream` async stream of alarm-feed messages and
|
||||
shares the gateway's central alarm monitor with every other client.
|
||||
`acknowledge_alarm` requires `operator_user` and carries `operator_password` (may be
|
||||
empty) and `operator_domain`; the gateway authenticates as that operator so the Historian
|
||||
records them. The password is scrubbed from any returned error's `Display`/`Debug`.
|
||||
|
||||
The `from_truncated_snapshot` field on `ActiveAlarmSnapshot` reports that the record came from
|
||||
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
||||
|
||||
@@ -174,6 +174,8 @@ alarm monitor — no worker session is opened — so any number of clients may
|
||||
attach. Dropping the stream cancels the gRPC call cooperatively.
|
||||
`acknowledge_alarm` is idempotent at the MxAccess layer; the returned
|
||||
`AcknowledgeAlarmReply` carries the native MxStatus from the worker.
|
||||
`operator_user` is required; `operator_password` may be empty and is scrubbed from any
|
||||
returned error via `Error::redact_secrets`.
|
||||
|
||||
## Galaxy Repository
|
||||
|
||||
@@ -346,7 +348,7 @@ mxgw write-secured-bulk ...
|
||||
mxgw write-secured2-bulk ...
|
||||
mxgw stream-events --session-id <id> --json
|
||||
mxgw stream-alarms [--filter-prefix <prefix>] [--max-events <n>]
|
||||
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] [--operator <user>]
|
||||
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] --operator <user> [--operator-domain <domain>] [--operator-password-env <VAR>=MXGATEWAY_OPERATOR_PASSWORD] [--operator-password <pw>]
|
||||
mxgw bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>]
|
||||
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
||||
mxgw batch
|
||||
|
||||
@@ -374,8 +374,19 @@ enum Command {
|
||||
reference: String,
|
||||
#[arg(long, default_value = "")]
|
||||
comment: String,
|
||||
#[arg(long, default_value = "")]
|
||||
/// Galaxy account of the acknowledging operator (required; the gateway
|
||||
/// rejects an empty operator so the real operator is recorded).
|
||||
#[arg(long)]
|
||||
operator: String,
|
||||
/// Galaxy domain of the operator; empty authenticates the bare user name.
|
||||
#[arg(long, default_value = "")]
|
||||
operator_domain: String,
|
||||
/// Operator password (prefer `--operator-password-env`; empty is allowed).
|
||||
#[arg(long)]
|
||||
operator_password: Option<String>,
|
||||
/// Environment variable holding the operator password.
|
||||
#[arg(long, default_value = "MXGATEWAY_OPERATOR_PASSWORD")]
|
||||
operator_password_env: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
@@ -1081,8 +1092,16 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
reference,
|
||||
comment,
|
||||
operator,
|
||||
operator_domain,
|
||||
operator_password,
|
||||
operator_password_env,
|
||||
json,
|
||||
} => {
|
||||
// An empty password is allowed (Galaxy accounts may have one), so
|
||||
// an unset flag and env resolve to an empty string.
|
||||
let operator_password = operator_password
|
||||
.or_else(|| env::var(&operator_password_env).ok())
|
||||
.unwrap_or_default();
|
||||
let client = connect(connection).await?;
|
||||
let reply = client
|
||||
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
||||
@@ -1090,6 +1109,8 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
alarm_full_reference: reference,
|
||||
comment,
|
||||
operator_user: operator,
|
||||
operator_password,
|
||||
operator_domain,
|
||||
})
|
||||
.await?;
|
||||
print_acknowledge_alarm_reply(&reply, json);
|
||||
@@ -2771,6 +2792,17 @@ mod tests {
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledge_alarm_requires_operator() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"mxgw",
|
||||
"acknowledge-alarm",
|
||||
"--reference",
|
||||
"Tank01.Level.HiHi",
|
||||
]);
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledge_alarm_requires_reference() {
|
||||
let parsed = Cli::try_parse_from(["mxgw", "acknowledge-alarm"]);
|
||||
|
||||
@@ -327,6 +327,9 @@ impl GatewayClient {
|
||||
/// MXAccess session; the resulting native MxStatus is returned in the
|
||||
/// reply. Acks are idempotent at the MxAccess layer.
|
||||
///
|
||||
/// `operator_user` is required; `operator_password` may be empty. The
|
||||
/// password is scrubbed from any returned error's `Display`/`Debug`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but
|
||||
@@ -336,13 +339,19 @@ impl GatewayClient {
|
||||
&self,
|
||||
request: AcknowledgeAlarmRequest,
|
||||
) -> Result<AcknowledgeAlarmReply, Error> {
|
||||
let secret = request.operator_password.clone();
|
||||
let mut client = self.inner.clone();
|
||||
let response = client
|
||||
.acknowledge_alarm(self.unary_request(request))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
||||
Ok(reply)
|
||||
let result = async {
|
||||
let response = client
|
||||
.acknowledge_alarm(self.unary_request(request))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
||||
Ok(reply)
|
||||
}
|
||||
.await;
|
||||
// The operator password must never surface in a returned error.
|
||||
result.map_err(|error: Error| error.redact_secrets(&[secret.as_str()]))
|
||||
}
|
||||
|
||||
/// Open the server-streaming `QueryActiveAlarms` RPC — the gateway's
|
||||
|
||||
@@ -314,6 +314,68 @@ impl std::fmt::Display for MxAccessError {
|
||||
|
||||
impl std::error::Error for MxAccessError {}
|
||||
|
||||
impl Error {
|
||||
/// Scrub exact caller-supplied secrets (for example an operator password)
|
||||
/// from the error's rendered text and any wrapped `tonic::Status`, so that
|
||||
/// neither `Display` nor `Debug` can reveal them. Empty secrets are ignored.
|
||||
#[must_use]
|
||||
pub fn redact_secrets(self, secrets: &[&str]) -> Self {
|
||||
let scrub = |text: &str| {
|
||||
let mut out = text.to_owned();
|
||||
for secret in secrets.iter().filter(|secret| !secret.is_empty()) {
|
||||
out = out.replace(*secret, "<redacted>");
|
||||
}
|
||||
out
|
||||
};
|
||||
let scrub_status = |status: Box<tonic::Status>| {
|
||||
Box::new(tonic::Status::new(status.code(), scrub(status.message())))
|
||||
};
|
||||
match self {
|
||||
Self::Authentication { message, status } => Self::Authentication {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Authorization { message, status } => Self::Authorization {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Timeout { message, status } => Self::Timeout {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Cancelled { message, status } => Self::Cancelled {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Unavailable { message, status } => Self::Unavailable {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Status(status) => Self::Status(scrub_status(status)),
|
||||
Self::ProtocolStatus {
|
||||
operation,
|
||||
code,
|
||||
message,
|
||||
} => Self::ProtocolStatus {
|
||||
operation,
|
||||
code,
|
||||
message: scrub(&message),
|
||||
},
|
||||
Self::MalformedReply { detail } => Self::MalformedReply {
|
||||
detail: scrub(&detail),
|
||||
},
|
||||
Self::InvalidArgument { name, detail } => Self::InvalidArgument {
|
||||
name,
|
||||
detail: scrub(&detail),
|
||||
},
|
||||
Self::MxAccess(error) => Self::MxAccess(Box::new(
|
||||
error.with_secrets(secrets.iter().map(|secret| (*secret).to_owned()).collect()),
|
||||
)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tonic::Status> for Error {
|
||||
fn from(status: tonic::Status) -> Self {
|
||||
let message = redact_credentials(status.message());
|
||||
@@ -461,6 +523,14 @@ fn redact_credentials(message: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn redact_secrets_scrubs_status_display_and_debug() {
|
||||
let error: super::Error = tonic::Status::internal("boom pw-secret").into();
|
||||
let error = error.redact_secrets(&["pw-secret", ""]);
|
||||
assert!(!format!("{error:?}").contains("pw-secret"));
|
||||
assert!(!error.to_string().contains("pw-secret"));
|
||||
}
|
||||
|
||||
use tonic::{Code, Status};
|
||||
|
||||
use super::{ensure_mxaccess_success, Error};
|
||||
|
||||
@@ -229,6 +229,8 @@ async fn acknowledge_alarm_returns_reply_with_native_status() {
|
||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||
comment: "investigating".to_owned(),
|
||||
operator_user: "alice".to_owned(),
|
||||
operator_password: String::new(),
|
||||
operator_domain: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1036,6 +1038,30 @@ async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acknowledge_alarm_error_does_not_leak_operator_password() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = client
|
||||
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
||||
client_correlation_id: "corr-pw".to_owned(),
|
||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||
comment: String::new(),
|
||||
operator_user: "alice".to_owned(),
|
||||
operator_password: "pw-secret".to_owned(),
|
||||
operator_domain: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(!format!("{err:?}").contains("pw-secret"));
|
||||
assert!(!err.to_string().contains("pw-secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_id() {
|
||||
// The CLI's `stream-alarms` and `acknowledge-alarm` paths used to
|
||||
@@ -1073,6 +1099,8 @@ async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_
|
||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||
comment: String::new(),
|
||||
operator_user: String::new(),
|
||||
operator_password: String::new(),
|
||||
operator_domain: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1424,8 +1452,11 @@ impl MxAccessGateway for FakeGateway {
|
||||
&self,
|
||||
request: Request<AcknowledgeAlarmRequest>,
|
||||
) -> Result<Response<AcknowledgeAlarmReply>, Status> {
|
||||
*self.state.last_correlation_id.lock().await =
|
||||
Some(request.into_inner().client_correlation_id);
|
||||
let request = request.into_inner();
|
||||
if request.operator_password == "pw-secret" {
|
||||
return Err(Status::internal("boom pw-secret"));
|
||||
}
|
||||
*self.state.last_correlation_id.lock().await = Some(request.client_correlation_id);
|
||||
Ok(Response::new(AcknowledgeAlarmReply {
|
||||
correlation_id: "corr-1".to_owned(),
|
||||
protocol_status: Some(ok_status("ack ok")),
|
||||
|
||||
@@ -621,8 +621,9 @@ returned to normal but is unacknowledged — i.e., visible in the
|
||||
"current alarms" list because operator hasn't acked it yet) and
|
||||
`UNACK_ALM` (the alarm is currently active and unacknowledged).
|
||||
The other states from `eAlmState` (`ACK_RTN`, `ACK_ALM`) would
|
||||
appear when an ack is performed — `wwAlarmConsumerClass.AlarmAckByGUID`
|
||||
is the method to call.
|
||||
appear when an ack is performed. The alarm-client ack methods
|
||||
(`AlarmAckByGUID` and friends) do not produce them headless; an `AckMsg`
|
||||
write does (`AlarmProbeFindings.md`).
|
||||
|
||||
### `GetStatistics` AV — unrelated quirk
|
||||
|
||||
@@ -660,7 +661,9 @@ alarm-consumer surface unblocks A.2 fully. Outline:
|
||||
events for added/changed/removed records.
|
||||
- `AlarmAckByGUID(VBGUID, comment, oprName, node, domain,
|
||||
fullName)` for client-driven acknowledgements (matches
|
||||
PR A.5's `AlarmAckCommand` payload).
|
||||
PR A.5's `AlarmAckCommand` payload). *Ack step superseded
|
||||
2026-10-19: acks write `AckMsg` as the authenticated operator
|
||||
(see "Acknowledge in subtag mode" and `AlarmProbeFindings.md`).*
|
||||
- Lifecycle teardown: `DeregisterConsumer` +
|
||||
`UninitializeConsumer` + `Marshal.FinalReleaseComObject`.
|
||||
3. **Conversion layer:** map XML record fields to
|
||||
@@ -701,8 +704,9 @@ alarm-consumer surface unblocks A.2 fully. Outline:
|
||||
`bRetainHiddenAlarms` parameter at all.
|
||||
- Subscription expression format: `\\<machine>\Galaxy!<area>`
|
||||
(literal `Galaxy` provider) for both libraries.
|
||||
- Native ack: `AlarmAckByGUID(VBGUID guid, comment, oprName,
|
||||
node, domain, fullName)` on the v2 surface; ID 5-arg
|
||||
- Native ack surface (not used by the gateway; superseded 2026-10-19 by
|
||||
the authenticated `AckMsg` write): `AlarmAckByGUID(VBGUID guid,
|
||||
comment, oprName, node, domain, fullName)` on the v2 surface; ID 5-arg
|
||||
variant on the legacy `IwwAlarmConsumer` interface.
|
||||
|
||||
These findings retire the open follow-up probes from the
|
||||
@@ -735,25 +739,32 @@ for `SetXmlAlarmQuery`.
|
||||
the canonical `\\<machine>\Galaxy!<area>` form into the XML's
|
||||
NODE/PROVIDER/GROUP fields. Mangled or not, the call enables reads.
|
||||
|
||||
### 2. Two consumers required: read-side vs. ack-side
|
||||
### 2. Two consumers required: read-side vs. ack-side (historical)
|
||||
|
||||
> Superseded 2026-10-19: acks now write `AckMsg` as the authenticated operator;
|
||||
> see `AlarmProbeFindings.md`. The worker no longer provisions an ack-only
|
||||
> wnwrap consumer. The text below records why the split existed.
|
||||
|
||||
`SetXmlAlarmQuery` enables reads but **breaks `AlarmAckByName` on
|
||||
the same consumer instance**. With SetXml applied, AlarmAckByName
|
||||
returns -55 even with valid name+provider+group+operator. Without
|
||||
SetXml, AlarmAckByName succeeds with rc=0.
|
||||
|
||||
The production consumer therefore provisions **two** wnwrap COM
|
||||
The consumer at the time provisioned **two** wnwrap COM
|
||||
instances:
|
||||
- Primary consumer (`client`): runs full lifecycle including
|
||||
`SetXmlAlarmQuery` for `GetXmlCurrentAlarms2` polls.
|
||||
- Ack-only consumer (`ackClient`): runs Initialize → Register →
|
||||
Subscribe via the v1-prefixed methods, **no SetXmlAlarmQuery**.
|
||||
All `AcknowledgeByName` calls dispatch through this instance.
|
||||
All by-name ack calls dispatched through this instance.
|
||||
|
||||
Both consumers subscribe to the same expression. Disposal cleans up
|
||||
both via a shared `ReleaseConsumerCom` helper.
|
||||
|
||||
### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg
|
||||
### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg (historical)
|
||||
|
||||
> Superseded 2026-10-19: the gateway no longer calls `AlarmAckByName`; see
|
||||
> `AlarmProbeFindings.md`.
|
||||
|
||||
`wwAlarmConsumerClass` exposes two `AlarmAckByName` overloads:
|
||||
- `IwwAlarmConsumer2` v2: 8 args (`name, provider, group, comment,
|
||||
@@ -762,8 +773,8 @@ both via a shared `ReleaseConsumerCom` helper.
|
||||
|
||||
The v2 8-arg method returns -55 on this AVEVA build regardless of
|
||||
operator-identity inputs — looks like a stub. The v1 6-arg method
|
||||
works. Production `WnWrapAlarmConsumer.AcknowledgeByName` calls the
|
||||
6-arg overload and discards the proto's `domain` + `full_name` fields.
|
||||
works. `WnWrapAlarmConsumer.AcknowledgeByName` called the
|
||||
6-arg overload and discarded the proto's `domain` + `full_name` fields.
|
||||
|
||||
**Correction, 2026-08-18** (`docs/AlarmProbeFindings.md`). Both halves
|
||||
of the paragraph above are narrower than they read. The -55 tracks the
|
||||
@@ -775,42 +786,34 @@ snapshot stay `UNACK_ALM`, `OPERATOR_NAME` stay empty, and the alarm
|
||||
extension's own `.Acked` attribute stay `False` for 16 s after each
|
||||
call. On this rig the wnwrap ack is accepted and then inert, so a zero
|
||||
return code must not be read as an applied acknowledgement.
|
||||
The proto contract keeps the 8 fields for forward compatibility if
|
||||
AVEVA fixes the v2 method later.
|
||||
|
||||
### 4. `AlarmAckByGUID` is not implemented
|
||||
### 4. `AlarmAckByGUID` is not implemented (historical)
|
||||
|
||||
> Superseded 2026-10-19: acks now write `AckMsg` as the authenticated operator;
|
||||
> see `AlarmProbeFindings.md`.
|
||||
|
||||
The v2 `AlarmAckByGUID(VBGUID, …)` throws `NotImplementedException`
|
||||
(COM `E_NOTIMPL`) on `wwAlarmConsumerClass` against this AVEVA
|
||||
build. The reference→GUID lookup that we initially planned to wire
|
||||
through `AlarmAckByGUID` is therefore not viable on wnwrap; all acks
|
||||
must go through `AlarmAckByName`.
|
||||
through `AlarmAckByGUID` is therefore not viable on wnwrap.
|
||||
|
||||
The proto `AcknowledgeAlarmCommand` (GUID-based) and the worker's
|
||||
`MxAccessCommandExecutor.ExecuteAcknowledgeAlarm` switch arm remain
|
||||
in the codebase for the forward-compat shape, but the gateway-side
|
||||
`WorkerAlarmRpcDispatcher.AcknowledgeAsync` now always routes through
|
||||
`AcknowledgeAlarmByName` when the public RPC supplies a recognizable
|
||||
`Provider!Group.Tag` reference.
|
||||
#### Managed GUID ack experiment — did not work (live-tested 2026-10-18, removed 2026-10-19)
|
||||
|
||||
**Opt-in managed GUID ack — does not work on this rig (live-tested
|
||||
2026-10-18).** Setting `MXGATEWAY_USE_MANAGED_ALARM_ACK=1` in the
|
||||
gateway's environment (workers inherit it) makes the worker build an
|
||||
`AaManagedAlarmAckConsumer`, which drives
|
||||
`aaAlarmManagedClient.AlarmClient` by reflection (its members are
|
||||
non-public) and is meant to take GUID-keyed acks in place of the
|
||||
wnwrap method. The live test shows it cannot do that as built, so leave
|
||||
the flag off.
|
||||
An opt-in experiment drove `aaAlarmManagedClient.AlarmClient` by reflection
|
||||
(its members are non-public) to take GUID-keyed acks in place of the wnwrap
|
||||
method. The live test showed it could not work, and the experiment and its
|
||||
enabling flag were removed once the `AckMsg` write replaced it. The findings
|
||||
are kept because they explain why the alarm-client route was abandoned.
|
||||
|
||||
The test raised `TestMachine_001.TestAlarm001` with a secured write,
|
||||
read its GUID from the wnwrap XML snapshot (the public API exposes no
|
||||
alarm GUIDs), and acknowledged it through the `AcknowledgeAlarm` RPC
|
||||
with the flag on:
|
||||
with the experiment enabled:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| RPC ack by GUID, flag off (control) | `NotImplementedException`; `.Acked` stays `False` |
|
||||
| RPC ack by GUID, flag on | Same `NotImplementedException`; `.Acked` `False`, `.AckMsg` empty |
|
||||
| RPC ack by GUID, experiment off (control) | `NotImplementedException`; `.Acked` stays `False` |
|
||||
| RPC ack by GUID, experiment on | Same `NotImplementedException`; `.Acked` `False`, `.AckMsg` empty |
|
||||
| `AlarmClient.RegisterConsumer` in a process with a registered wnwrap consumer | `-65` — and wnwrap gets `-65` if the managed client registered first; a real `hWnd` does not help |
|
||||
| Managed client alone in its own process, records loaded | `AlarmAckByGUID` → `-1`, `SelectByGUID` → `-1` for the wnwrap GUID in both byte orders, `AlarmAckSelected` → `-57` |
|
||||
|
||||
@@ -820,7 +823,7 @@ Three separate problems follow:
|
||||
a wnwrap reader, so the managed `RegisterConsumer` fails with `-65`.
|
||||
The dispatcher is built to drop back to the wnwrap ack path on a
|
||||
lifecycle failure (so alarm reads keep working), and it does so
|
||||
silently — which is why the flag-on RPC is indistinguishable from the
|
||||
silently — which is why the experiment-on RPC was indistinguishable from the
|
||||
control.
|
||||
2. **The managed client does not recognise the wnwrap GUID.** `-1` from
|
||||
`SelectByGUID` is "not found", not a security refusal, even with the
|
||||
@@ -833,14 +836,8 @@ Three separate problems follow:
|
||||
strings, so the domain and full name the managed method carries
|
||||
would not arrive through the public RPC even if 1 and 2 were solved.
|
||||
|
||||
A working managed ack would need the client hosted out of process, a way
|
||||
to resolve the alarm to the identifier the managed client uses, and the
|
||||
identity fields plumbed through the gateway. Routing rules the worker
|
||||
already applies stay correct and would carry over: subtag-mode acks
|
||||
(synthetic GUIDs) stay on the subtag consumer, and by-name acks stay on
|
||||
wnwrap because `AlarmClient` has no by-name ack. The skip-gated
|
||||
`AlarmManagedGuidAckLiveSmokeTests` exercises the in-process
|
||||
arrangement and hits problem 1.
|
||||
The route was dropped in favour of the authenticated `AckMsg` write
|
||||
(`AlarmProbeFindings.md`, "Fourth attempt").
|
||||
|
||||
### 5. STA / threading — resolved
|
||||
|
||||
@@ -996,20 +993,24 @@ AVEVA-internal GUID.
|
||||
|
||||
### Acknowledge in subtag mode
|
||||
|
||||
`AlarmDispatcher` routes ack calls by active provider mode:
|
||||
Both provider modes acknowledge through the worker's `AlarmAckWriter`, which
|
||||
calls `AuthenticateUser` as the requesting operator and writes the comment to
|
||||
the ack item over MXAccess on its own `LMXProxyServer`:
|
||||
|
||||
- **Alarm-manager mode:** `AlarmAckByName` on `wwAlarmConsumerClass` (unchanged).
|
||||
- **Subtag mode:** `SubtagAlarmConsumer.AcknowledgeByName` resolves the
|
||||
watch-list entry's `ack_comment_subtag` and issues a `Write(comment)` on
|
||||
the STA via `LmxSubtagAlarmSource`. Writing the `AckMsg` subtag performs
|
||||
the acknowledge in AVEVA (`AckMsg` is the confirmed `AlarmExtension` ack-comment
|
||||
write target).
|
||||
- **Alarm-manager mode:** the item is `<TagName>.AckMsg` for Galaxy-provider
|
||||
alarms. An alarm from another provider is not ackable.
|
||||
- **Subtag mode:** `SubtagAlarmConsumer` resolves the watch-list entry's
|
||||
`ack_comment_subtag` and the writer writes that item verbatim. Writing the
|
||||
`AckMsg` subtag performs the acknowledge in AVEVA (`AckMsg` is the confirmed
|
||||
`AlarmExtension` ack-comment write target).
|
||||
|
||||
If the alarm has no writable ack-comment subtag (`AckComment` config key is
|
||||
empty, or the entry's `ack_comment_subtag` field is empty), the ack call
|
||||
returns a failure code that the gateway surfaces as `FailedPrecondition`.
|
||||
`AcknowledgeByGuid` maps the synthetic GUID back to its reference via an
|
||||
internal dictionary, then calls the same write path.
|
||||
Because the write is authenticated, the Historian records the operator's name,
|
||||
domain and full name, and the writer's MXAccess client name
|
||||
(`MxGateway:Alarms:AckClientName`) as OperatorNode. If the alarm has no ack
|
||||
target (`AckComment` config key is empty, the entry's `ack_comment_subtag` is
|
||||
empty, or the reference or synthetic GUID is unknown), the worker reports it
|
||||
as not found or not ackable and the gateway surfaces `InvalidRequest` with
|
||||
`hresult` -1.
|
||||
|
||||
`SubtagAlarmConsumer.Subscribe` advises the ack-comment subtag alongside the
|
||||
observed ones (active/acked/priority). This is required: MXAccess rejects a
|
||||
@@ -1027,7 +1028,7 @@ The subtag path was validated against live MXAccess on the dev rig
|
||||
`….Priority` → `500` (Int32), `….AckMsg` → string — confirming the field
|
||||
names **and** the runtime reference shape `<Object>.<AlarmAttr>.<field>`
|
||||
with **no** intermediate alarm-condition segment.
|
||||
- `AcknowledgeByName` (AckMsg write) returned `0` once the ack-comment subtag
|
||||
- The `AckMsg` write returned `0` once the ack-comment subtag
|
||||
was advised — confirming the ack-by-comment-write mechanism end to end.
|
||||
|
||||
### Fidelity limitations
|
||||
|
||||
+127
-2
@@ -24,13 +24,15 @@ the blocker rather than rediscovering it.
|
||||
|---|---|
|
||||
| GUID stable across polls and `ALM → RTN` | Answered — yes (2026-05-01 capture in `AlarmClientDiscovery.md`, reconfirmed 2026-08-18) |
|
||||
| GUID stable across clear-then-re-raise | Answered 2026-08-18 — **no**, a re-raise mints a new GUID |
|
||||
| GUID stable across `UNACK → ACK` | **Open** — the rig cannot be driven into an acknowledged state; 2026-08-18 (third attempt) identifies the reason as the test attribute's `MxSecurityOperate` classification, which no non-interactive ack surface on the rig can satisfy |
|
||||
| GUID stable across `UNACK → ACK` | Blocker resolved 2026-10-19 — the rig is driven to `ACK_ALM` by an `AckMsg` write, and the `MxSecurityOperate` classification was not the cause (see "Fourth attempt"); GUID survival across that transition has not been recorded yet |
|
||||
| Acknowledge an alarm headless | Answered 2026-10-19 — **an `AckMsg` write as the authenticated operator**; every alarm-client ack is inert (see "Fourth attempt") |
|
||||
| `COUNT` = total active vs records-in-reply under a capped fetch | Answered 2026-08-18 — **records in the reply** |
|
||||
|
||||
The 2026-08-17 run below is kept because it is the record of the wrong-verb blocker. The
|
||||
2026-08-18 run cleared that blocker with `AuthenticateUser` + `WriteSecured` and answered
|
||||
two of the three questions; the acknowledge leg is now blocked on something narrower and
|
||||
different, described in "Second attempt" and diagnosed in "Third attempt".
|
||||
different, described in "Second attempt" and diagnosed in "Third attempt"
|
||||
(resolved in the Fourth attempt, 2026-10-19).
|
||||
|
||||
## First attempt (2026-08-17): plain `Write`
|
||||
|
||||
@@ -420,3 +422,126 @@ The second attempt's inventory, unchanged since: the three
|
||||
snapshot, since nothing can acknowledge them away. `SnapshotActiveAlarms` counts only
|
||||
`UNACK_ALM` and `ACK_ALM` as active, so these are inert for the gateway; they will clear on
|
||||
the next `alarmmgr` restart.
|
||||
|
||||
## Fourth attempt (2026-10-19): authenticated AckMsg write
|
||||
|
||||
The third attempt left the acknowledge leg blocked. This run found a route that
|
||||
acknowledges and identifies the operator, and ruled out the alarm-client routes.
|
||||
It drove `TestMachine_001.TestAlarm001`, raised with
|
||||
`mxa write TestMachine_001.TestAlarm001 true --type bool --secured -u Administrator`.
|
||||
|
||||
### Writing `AckMsg` acknowledges
|
||||
|
||||
Writing the comment to `TestMachine_001.TestAlarm001.AckMsg` over MXAccess
|
||||
acknowledges the alarm: `.Acked` becomes `True` and the wnwrap record moves to
|
||||
`STATE=ACK_ALM` with an `ACK_COMMENT`. `AckMsg` is `MxSecurityFreeAccess` even
|
||||
though the alarm UDA itself is `MxSecurityOperate`, so no Galaxy change is
|
||||
needed.
|
||||
|
||||
What the Historian records, read from `Runtime.dbo.v_AlarmEventHistory2`:
|
||||
|
||||
| Write | Operator | DomainName | UserFullName | OperatorNode | Description |
|
||||
|---|---|---|---|---|---|
|
||||
| Unauthenticated `Write` | `NULL` | `NULL` | `NULL` | `NULL` | the comment |
|
||||
| `AuthenticateUser` + `Write(value, userId)` | `Administrator` | `ArchestrA` | `Administrator` | `mxa` (the MXAccess `Register()` client name) | the comment |
|
||||
|
||||
The wnwrap XML also gains `ACK_OPERATOR_NAME`, `ACK_DOMAIN_NAME`,
|
||||
`ACK_OPERATOR_NODE` and `ACK_COMMENT` on the acknowledged record; the worker's
|
||||
snapshot parser reads them for Acknowledge transitions. An unauthenticated write
|
||||
therefore acknowledges but leaves no operator in the Historian, which is why the
|
||||
gateway requires `operator_user` and authenticates it.
|
||||
|
||||
Another actor's `AckMsg` writes on the same day (authenticated as another
|
||||
operator account) logged that account as `Operator`, `Domain="."` and that machine's name as the node,
|
||||
which matches the client-name rule. By this date unauthenticated writes to
|
||||
`MxSecurityOperate` attributes were also accepted Galaxy-wide on the rig, where
|
||||
August refused them with 1008.
|
||||
|
||||
### Alarm-client acks are inert in every variant
|
||||
|
||||
All of these returned `rc=0` or `-1` and changed neither the snapshot, `.Acked`
|
||||
nor the Historian:
|
||||
|
||||
- wnwrap `AlarmAckByName` and `AlarmAckByGUID` (`E_NOTIMPL`) on the ack-only and
|
||||
reader consumers.
|
||||
- wnwrap `IwwAlarmConsumer2` on a sole consumer with `SetXmlAlarmQuery` applied
|
||||
(list populated, `GetAlarm(0)` returns the row): `AlarmAck` with ids
|
||||
0/1/2/3/-1/65536, and `Select(0)` + `AlarmAckSelected`, with operators
|
||||
`TestOperator`, `Administrator` and a third account. `SelectByGUID` is `E_NOTIMPL`.
|
||||
- The same calls with `TestAlarm001` temporarily reclassified to
|
||||
`MxSecurityFreeAccess` (template and instance, restored afterwards), so the
|
||||
security classification is not the cause.
|
||||
- Managed `aaAlarmManagedClient`: `AlarmAckByGUID` and `SelectByGUID` return
|
||||
`-1` (the client's list never fills headless, even with a real `hWnd` and a
|
||||
message pump); `AckSelectedTagAlarms_Ex` returns `0` even for a
|
||||
`NOSUCHPROVIDER` provider and writes no Historian row. The managed and wnwrap
|
||||
consumers cannot share a process (`RegisterConsumer` returns `-65`).
|
||||
- A 20 s continuous message pump after `AlarmAck`, and registering as `InTouch`,
|
||||
`WindowViewer` or `Wonderware InTouch`.
|
||||
|
||||
Static reverse engineering matched: the five-string COM acks forward domain and
|
||||
full name with an empty password, and InTouch's `AlarmViewer.ocx` also sends an
|
||||
empty password. The user-request call is synchronous request-to-return-code with
|
||||
no completion channel, so the drop happens provider-side. Hooking the user
|
||||
request in a live WindowViewer that acknowledges successfully and diffing it
|
||||
against the headless probe is the one definitive step not taken.
|
||||
|
||||
### Consequence
|
||||
|
||||
The gateway acknowledges by authenticating the operator and writing `AckMsg`
|
||||
(`docs/DesignDecisions.md`, "Alarm acknowledgement writes AckMsg as the
|
||||
authenticated operator"). The managed ack consumer and the second wnwrap ack
|
||||
consumer were removed.
|
||||
|
||||
### Gateway-level verification
|
||||
|
||||
The same result holds end to end through the gateway. With the feature build deployed on
|
||||
the windev rig and `TestAlarm001` raised, an `AcknowledgeAlarm` RPC for
|
||||
`Galaxy!TestArea.TestMachine_001.TestAlarm001` with `operator_user=Administrator` and an
|
||||
empty password returned `PROTOCOL_STATUS_CODE_OK`, `hresult` 0. `.Acked` turned True,
|
||||
`.AckMsg` held the comment, and the Historian logged the `ACK_ALM` row with
|
||||
`Operator=Administrator`, `DomainName=ArchestrA`, `UserFullName=Administrator`,
|
||||
`OperatorNode=MxGateway` (the default `AckClientName`), and `Description` = the comment.
|
||||
|
||||
A wrong password, or a user Galaxy security does not know, makes `AuthenticateUser`
|
||||
throw `ArgumentException` (`E_INVALIDARG`, 0x80070057) rather than a `COMException`.
|
||||
That is why the writer treats `ArgumentException` from the MXAccess call itself as an
|
||||
interop failure alongside `COMException`, and reports the HRESULT the exception carries.
|
||||
Such an ack returns `PROTOCOL_STATUS_CODE_MXACCESS_FAILURE`, the alarm stays unacked,
|
||||
the Historian gains no ack row, and the password string does not appear in the gateway
|
||||
log, `stdout.log`, or `stderr.log`. A non-Galaxy provider reference returns
|
||||
`PROTOCOL_STATUS_CODE_INVALID_REQUEST` with `hresult` -1, and an empty `operator_user`
|
||||
returns gRPC `InvalidArgument`.
|
||||
|
||||
The GUID arm of the ack command was not driven live. The public alarm RPCs expose only
|
||||
the full reference, so clients reach the by-name arm; worker unit tests cover the GUID
|
||||
arm.
|
||||
|
||||
## Item handle reuse
|
||||
|
||||
`AlarmAckWriter` correlates an ack with its `OnWriteComplete` by the
|
||||
(server handle, item handle) pair, because the callback carries no per-write id.
|
||||
After an unconfirmed ack it drops the `AckMsg` item and re-adds it so the next
|
||||
ack waits on a new handle that a late completion for the old one cannot match.
|
||||
That only works if MXAccess does not hand the same number out again.
|
||||
|
||||
`ItemHandleReuseProbeTests` (Skip-gated, worker tests) checked this on windev
|
||||
(`10.100.0.48`, Galaxy `DEV`) on 2026-10-20 against
|
||||
`TestMachine_001.TestAlarm001.AckMsg`, without writing:
|
||||
|
||||
| Sequence on one registration (server handle 1) | Item handles returned |
|
||||
|---|---|
|
||||
| `AddItem` + `Advise`, then `UnAdvise` + `RemoveItem`, five times | 1, 2, 3, 4, 5 |
|
||||
| `AddItem`, then `RemoveItem`, five times | 6, 7, 8, 9, 10 |
|
||||
| Add and remove `AckMsg`, add `Acked`, add `AckMsg` again | 11, then 12 (`Acked`), 13 |
|
||||
| Add `AckMsg` twice while both are held | 14, 15 |
|
||||
|
||||
Within a registration the numbers increase and a freed number is never reused,
|
||||
so the drop-and-re-add mitigation holds. Numbering is per registration:
|
||||
`Unregister` + `Register` on the same COM object returned server handle 2 and
|
||||
item handle 1, and a new `LMXProxyServerClass` returned server handle 1 and
|
||||
item handle 1 again. The writer is not exposed to that restart because its
|
||||
teardown detaches its `OnWriteComplete` handler from the old COM object and
|
||||
clears that server handle's completion entries before it creates a new one.
|
||||
No per-handle generation counter is needed.
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ public string ResolveRequiredScope(object request)
|
||||
}
|
||||
```
|
||||
|
||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*`. It stays `invoke:write` rather than `invoke:secure` because the request carries the operator's own Galaxy credential, which the worker authenticates before the `AckMsg` write; the credential, not the API-key scope, authorizes the identity the Historian records. The trade-off is that an `invoke:write` key can tell `AuthenticationFailed` (an `MXACCESS_FAILURE` whose diagnostic reports operator authentication failure) apart from every other outcome, which makes `AcknowledgeAlarm` a Galaxy-password oracle for that key — whereas the `AuthenticateUser` command itself requires `invoke:secure`. What bounds it: the caller must already hold a valid, admin-issued, revocable `invoke:write` key, and every acknowledgement is serialized through the single monitor worker session's STA, so guesses run one at a time. The gateway does **not** throttle failed operator authentications: the `ApiKeyFailureLimiter` counts only failed API-key verifications, and the ASP.NET Core rate limiter covers only the dashboard `/login` endpoint. Any lockout after repeated bad passwords comes from the Galaxy security configuration, not from the gateway. Grant `invoke:write` only to clients trusted with that exposure. `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||
|
||||
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
|
||||
|
||||
|
||||
@@ -49,6 +49,17 @@ worker prepares the replacement on its STA before swapping it into the existing
|
||||
failover consumer and returns a normal `MxCommandReply`; callers must treat only
|
||||
`protocol_status = OK` as acknowledgement of the new list.
|
||||
|
||||
`AcknowledgeAlarmRequest` carries the operator's identity: `operator_user`
|
||||
(required), `operator_password` (field 6) and `operator_domain` (field 7). The
|
||||
gateway copies them onto `AcknowledgeAlarmCommand` (`operator_password` = 7) and
|
||||
`AcknowledgeAlarmByNameCommand` (`operator_password` = 9); the worker
|
||||
authenticates the operator and writes `AckMsg` as that user. The fields are
|
||||
additive, so `GatewayProtocolVersion` stays `3`. `operator_password` is a
|
||||
credential and follows the same never-log rule as
|
||||
`AuthenticateUserCommand.verify_user_password`: it must not reach logs, metric
|
||||
labels, `DiagnosticMessage`, `ProtocolStatus.Message`, or exception text. A
|
||||
not-found or not-ackable alarm is reported with `hresult` -1.
|
||||
|
||||
The command model also includes bulk write/read command kinds:
|
||||
`WriteBulk`, `Write2Bulk`, `WriteSecuredBulk`, `WriteSecured2Bulk`, and
|
||||
`ReadBulk`. They are unary `Invoke` payloads on the same `MxAccessGateway`
|
||||
|
||||
+50
-8
@@ -703,14 +703,15 @@ latch in subtag mode.
|
||||
|
||||
### Acknowledge via ack-comment write
|
||||
|
||||
In subtag mode, `AcknowledgeAlarm` writes the operator comment to the alarm
|
||||
attribute's ack-comment subtag (`Fallback:Subtags:AckComment`). The write
|
||||
performs the native ack in AVEVA. This differs from alarmmgr mode, where
|
||||
`AlarmAckByName` on `wwAlarmConsumerClass` is called directly. The `AckComment`
|
||||
subtag name is empty by default; configuring it is required for ack to work in
|
||||
subtag mode. The exact AVEVA subtag names are not hard-coded — the `Subtags`
|
||||
config block exists precisely so names are not guessed without validation
|
||||
against the live MXAccess attribute set.
|
||||
Both provider modes acknowledge by writing the operator comment to `AckMsg` as
|
||||
the authenticated operator (see "Alarm acknowledgement writes AckMsg as the
|
||||
authenticated operator" below). In alarm-manager mode the target is
|
||||
`<TagName>.AckMsg`; in subtag mode it is the watch-list entry's ack-comment
|
||||
subtag (`Fallback:Subtags:AckComment`, default `AckMsg`). The write performs
|
||||
the ack in AVEVA. An entry with an empty ack-comment subtag is not ackable and
|
||||
the RPC reports `InvalidRequest`. The exact AVEVA subtag names are not
|
||||
hard-coded — the `Subtags` config block exists so names are not guessed
|
||||
without validation against the live MXAccess attribute set.
|
||||
|
||||
### Related documentation
|
||||
|
||||
@@ -784,6 +785,47 @@ The following items were previously listed here and have since shipped:
|
||||
`MxGateway:Sessions:MaxEventSubscribersPerSession`.
|
||||
See [Event Subscribers](#event-subscribers) above and [Sessions](./Sessions.md).
|
||||
|
||||
## Alarm acknowledgement writes AckMsg as the authenticated operator
|
||||
|
||||
**Decision (2026-10-19).** `AcknowledgeAlarm` authenticates the requesting
|
||||
operator with `AuthenticateUser` (`operator_user`, `operator_password`,
|
||||
optional `operator_domain`) and writes the comment to `<TagName>.AckMsg` over
|
||||
MXAccess as that user, on a dedicated `LMXProxyServer` registered as
|
||||
`MxGateway:Alarms:AckClientName`. Both provider modes use this path.
|
||||
|
||||
**Why.** Live tests (2026-10-18/19) showed every alarm-client ack is inert on a
|
||||
headless consumer: wnwrap `AlarmAckByName`/`AlarmAckByGUID`/`AlarmAck`/
|
||||
`AlarmAckSelected`, and the managed `aaAlarmManagedClient` GUID ack, return
|
||||
rc=0 or -1 and change nothing, regardless of security classification,
|
||||
operator name or consumer name. An MXAccess write to `AckMsg` does acknowledge
|
||||
the alarm, and when the write is made under an authenticated user the AVEVA
|
||||
runtime historizes that identity (`Operator`, `DomainName`, `UserFullName`),
|
||||
with the MXAccess client name as `OperatorNode`. The requirement is that the
|
||||
real operator appears in the Historian, as with InTouch, so a shared gateway
|
||||
service account was rejected. Operators have Galaxy accounts, so the
|
||||
per-request credential is workable. See
|
||||
[Alarm Probe Findings](./AlarmProbeFindings.md).
|
||||
|
||||
**Consequences.**
|
||||
|
||||
- `operator_user` is required; `operator_password` may be empty.
|
||||
- `operator_password` is a credential. It is forwarded to the worker over the
|
||||
pipe but never logged: `AcknowledgeAlarm` and `AcknowledgeAlarmByName` are
|
||||
credential-bearing commands and any property named like `password` is
|
||||
redacted.
|
||||
- `Applied` and `Unconfirmed` (write sent, no `OnWriteComplete` in the bounded
|
||||
wait) both report `OK`; clients that need certainty watch for the transition.
|
||||
- The managed ack consumer, the WNAL native ack consumer, the second wnwrap
|
||||
ack-only consumer and the opt-in managed-ack environment flag are removed.
|
||||
|
||||
**Upgrade note.** This is a breaking change for callers. They must now send
|
||||
`operator_user` (required; empty is rejected with `InvalidArgument`) and the
|
||||
operator's Galaxy password; a caller that sends no password for a
|
||||
password-protected account gets `MXACCESS_FAILURE` from `AuthenticateUser`
|
||||
instead of an acknowledgement. OtOpcUa's `GalaxyDriver` alarm acknowledger is a
|
||||
known caller that must be updated to forward the operator's credentials before
|
||||
the gateway is upgraded.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
||||
|
||||
+5
-3
@@ -4,7 +4,7 @@ The diagnostics subsystem provides structured logging, credential redaction, and
|
||||
|
||||
## Goals
|
||||
|
||||
The subsystem exists to satisfy two security rules from `gateway.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly.
|
||||
The subsystem exists to satisfy two security rules from `gateway.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, `AcknowledgeAlarm` (its `operator_password`), or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly.
|
||||
|
||||
A second goal is parity-test diagnosability. Because MXAccess sessions, workers, correlation ids, and command methods are the units of comparison, every log entry produced inside a request scope must carry those identifiers without each call site having to format them.
|
||||
|
||||
@@ -70,7 +70,9 @@ private static readonly HashSet<string> SensitiveCommandMethods = new(StringComp
|
||||
{
|
||||
"AuthenticateUser",
|
||||
"WriteSecured",
|
||||
"WriteSecured2"
|
||||
"WriteSecured2",
|
||||
"AcknowledgeAlarm",
|
||||
"AcknowledgeAlarmByName"
|
||||
};
|
||||
|
||||
public static bool IsCredentialBearingCommand(string? commandMethod)
|
||||
@@ -80,7 +82,7 @@ public static bool IsCredentialBearingCommand(string? commandMethod)
|
||||
}
|
||||
```
|
||||
|
||||
The names match the MXAccess command list in `gateway.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below.
|
||||
The names match the MXAccess command list in `gateway.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below. `AcknowledgeAlarm` and `AcknowledgeAlarmByName` are in the set because their commands carry the operator's `operator_password`. Independently of the method set, the name-based seam redacts any property whose name contains `password`, so a credential field added to a payload later is redacted without a code change.
|
||||
|
||||
### API key redaction
|
||||
|
||||
|
||||
@@ -442,17 +442,18 @@ behavior.
|
||||
| `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor re-resolves and, on material change, hot-swaps the subtag fallback watch-list, then reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot. The acknowledged watch-list fingerprint advances only after an OK worker reply. Floored at 5 seconds. |
|
||||
| `MxGateway:Alarms:PollIntervalMilliseconds` | `500` | Cadence at which the worker's STA polls the AVEVA alarm consumer (`GetXmlCurrentAlarms2`) for the active-alarm snapshot the live feed diffs. Must be between `100` and `3600000` (one hour): every poll is a COM call plus an XML parse on the same STA that serves reads and writes, so a tighter cadence starves the command path, while a value above an hour stops being a cadence and silently disables alarm polling. The gateway conveys the value to the worker via the `MXGATEWAY_ALARM_POLL_INTERVAL_MS` environment variable; a missing or out-of-range value leaves the worker on the 500 ms default rather than failing the session. |
|
||||
| `MxGateway:Alarms:MaxAlarmsPerFetch` | `1024` | Cap the worker passes to `GetXmlCurrentAlarms2`'s `maxAlmCnt`. Must be between `64` and `65536` — the worker is a 32-bit process that materializes each reply as one BSTR plus a full `XmlDocument`, so an unbounded cap faults the STA with an out-of-memory rather than merely slowing it. It doubles as the **truncation threshold**: a fetch returning exactly this many records is treated as truncated, because the COM API caps its reply with no "more available" flag. On a truncated poll the worker retains the alarms the capped reply could not mention instead of letting their absence read as a clear, and logs a rate-limited `AlarmSnapshotTruncated` warning to its stderr (identifiers and counts only). **Remediation when you see that warning: raise this value** so the steady-state active-alarm count fits inside one fetch. A galaxy permanently above the cap holds stale entries in the snapshot until a sub-cap poll, and loses clear→re-raise cycles that happen entirely out of window (see `docs/DesignDecisions.md`). Conveyed to the worker via the `MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH` environment variable; a missing or out-of-range value leaves the worker on the 1024 default. |
|
||||
| `MxGateway:Alarms:AckClientName` | `MxGateway` | MXAccess client name the alarm worker's ack writer registers with; the Historian records it as each acknowledgement's OperatorNode. 1-64 printable characters. Conveyed via `MXGATEWAY_ALARM_ACK_CLIENT_NAME`. |
|
||||
|
||||
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
||||
`StreamAlarms` are session-less RPCs served by the monitor.
|
||||
|
||||
The worker also reads `MXGATEWAY_USE_MANAGED_ALARM_ACK` (inherited from the
|
||||
gateway process). Setting it to `1` is meant to route GUID-keyed acknowledges
|
||||
through AVEVA's managed alarm client, but a live test (2026-10-18) showed the
|
||||
path cannot work as built: the managed client fails to register alongside the
|
||||
worker's wnwrap reader and the worker silently falls back to wnwrap. Leave it
|
||||
unset. It has no `MxGateway:*` option. See
|
||||
[AlarmClientDiscovery](./AlarmClientDiscovery.md) ("Opt-in managed GUID ack").
|
||||
`AckClientName` is the MXAccess client name the alarm worker's ack writer
|
||||
registers its dedicated `LMXProxyServer` with. The Historian records it as the
|
||||
OperatorNode of each acknowledgement, so it identifies the gateway as the
|
||||
acknowledging node. The gateway passes it to each worker through the
|
||||
`MXGATEWAY_ALARM_ACK_CLIENT_NAME` environment variable. Startup validation
|
||||
rejects an empty value, a value over 64 characters, or one containing control
|
||||
characters.
|
||||
|
||||
### Alarm fallback options
|
||||
|
||||
@@ -471,7 +472,7 @@ native wnwrap alarm-manager provider and the subtag-monitoring fallback.
|
||||
| `MxGateway:Alarms:Fallback:Discovery:ExcludeAttributes` | _(empty)_ | Attribute paths to remove from the merged watch-list (case-insensitive). The exclude is applied after the Repository-derived rows and the explicit `IncludeAttributes` entries are combined, so an exclude that matches an explicit include suppresses it too — excludes win. Ignored when `UseGalaxyRepository` is `false`. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:Active` | `InAlarm` | Subtag name for the in-alarm boolean. Confirmed AVEVA `AlarmExtension` field name. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:Acked` | `Acked` | Subtag name for the acknowledged boolean. Confirmed AVEVA `AlarmExtension` field name. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the ack-comment write path is disabled. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag, as the authenticated operator, performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the alarm is not ackable and the RPC returns `InvalidRequest`. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:Priority` | `Priority` | Subtag name for the alarm priority / severity value. Confirmed AVEVA `AlarmExtension` field name. |
|
||||
|
||||
Validation rules:
|
||||
|
||||
+24
-2
@@ -96,9 +96,30 @@ Selected control commands have additional public behavior beyond their protobuf
|
||||
|
||||
### `AcknowledgeAlarm`
|
||||
|
||||
`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`.
|
||||
`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` and `operator_user` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`.
|
||||
|
||||
An `OK` response means the alarm provider accepted the acknowledgement, not that it applied it: the gateway forwards the ack and reports what the provider returned, in keeping with the parity rule. On galaxies whose alarmed attributes carry a non-free-access security classification the by-name ack is accepted and inert — the underlying `AlarmAckByName` conveys an operator name rather than an authenticated identity, observed on the probe rig with the mechanism inferred rather than confirmed — so a client that needs to confirm an acknowledgement should watch for the resulting transition on `StreamAlarms` instead of treating `OK` as proof. See [Alarm Probe Findings](./AlarmProbeFindings.md).
|
||||
Request fields that identify the acknowledging operator:
|
||||
|
||||
- `operator_user` (required): the Galaxy account name. An empty value is rejected with `InvalidArgument` so the real operator is always recorded.
|
||||
- `operator_password` (field 6): the operator's Galaxy password. May be empty because Galaxy accounts can have empty passwords. It is a credential and is never logged.
|
||||
- `operator_domain` (field 7): optional. When set, the worker authenticates `domain\user`; when empty it authenticates `user`.
|
||||
|
||||
The worker calls `AuthenticateUser` as the operator, then writes `operator_comment` to `<TagName>.AckMsg` (subtag mode: the configured ack-comment subtag) on a dedicated MXAccess server registered as `MxGateway:Alarms:AckClientName`, and waits a bounded time for `OnWriteComplete`. The AVEVA runtime treats that write as the acknowledgement, and the Historian (`Runtime.dbo.v_AlarmEventHistory2`) records the operator name, domain and full name from the authenticated account, with the ack client name as `OperatorNode`. The alarm-client ack calls (`AlarmAckByName`, `AlarmAckByGUID`) are not used because a headless consumer's acks are accepted and dropped; see [Alarm Probe Findings](./AlarmProbeFindings.md).
|
||||
|
||||
| Worker outcome | Response status | `hresult` |
|
||||
|---|---|---|
|
||||
| Write applied, or sent but no `OnWriteComplete` within the wait (unconfirmed, with a diagnostic) | `OK` | `0` |
|
||||
| Unknown alarm, or no ack target (non-Galaxy provider, no ack-comment subtag) | `INVALID_REQUEST` | `-1` |
|
||||
| `AuthenticateUser` raised an MXAccess error (bad user or wrong password surfaces as `E_INVALIDARG` `0x80070057`, raised by the interop as `ArgumentException`; `COMException` and `UnauthorizedAccessException` are classified the same way) | `MXACCESS_FAILURE` | the exception's HRESULT |
|
||||
| `AuthenticateUser` returned user id `0` | `MXACCESS_FAILURE` | `-1` |
|
||||
| `AuthenticateUser` failed with a server-gone HRESULT (`RPC_E_DISCONNECTED`, `RPC_S_SERVER_UNAVAILABLE`, `RPC_E_SERVERFAULT`); the writer tears down its MXAccess registration and re-registers on the next ack | `MXACCESS_FAILURE` | that HRESULT |
|
||||
| `AddItem`, `Advise` or `Write` on the `AckMsg` item raised an MXAccess error (`COMException`, or `ArgumentException` for `E_INVALIDARG`); the item is dropped and re-added on the next ack, and a server-gone HRESULT also tears down the registration | `MXACCESS_FAILURE` | the exception's HRESULT |
|
||||
| Write rejected (`OnWriteComplete` non-OK) | `MXACCESS_FAILURE` | the `detail` of the `OnWriteComplete` `MXSTATUS_PROXY`, or `-1` when `detail` is `0` |
|
||||
| Any other exception escaping the ack (e.g. the ack writer's MXAccess `Register` failing, the monitor's worker session having no active alarm subscription — `InvalidOperationException` — or an exception from the worker's own code rather than from an MXAccess call: interop failures are classified only when the `AuthenticateUser`, `AddItem`, `Advise` or `Write` call itself raises them, and an `ArgumentNullException` or `ArgumentOutOfRangeException` is never classified because the interop does not raise them for an HRESULT) | `MXACCESS_FAILURE` | the exception's `HResult`; the diagnostic names only the exception type and HRESULT |
|
||||
|
||||
`reply.Statuses` carries the `OnWriteComplete` `MxStatusProxy` rows when they were observed. Because an unconfirmed write also returns `OK`, a client that must be certain should watch for the resulting transition on `StreamAlarms`. `AcknowledgeAlarm` stays under the `invoke:write` scope; see [Authorization](./Authorization.md).
|
||||
|
||||
**Upgrade note.** Callers must now send `operator_user` (required) and the operator's Galaxy password in `operator_password`. A caller that sends no `operator_user` gets `InvalidArgument`; a caller that sends no password for a password-protected Galaxy account gets `MXACCESS_FAILURE` from `AuthenticateUser` instead of an acknowledgement. Every existing caller must be updated before the gateway is upgraded — known callers include OtOpcUa's `GalaxyDriver` alarm acknowledger, which must supply the operator's credentials.
|
||||
|
||||
### `StreamAlarms`
|
||||
|
||||
@@ -217,6 +238,7 @@ receive this event directly.
|
||||
| `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` |
|
||||
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
|
||||
| `AcknowledgeAlarm` | `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
|
||||
| `AcknowledgeAlarm` | `operator_user` must be non-empty. Validated inline in the handler. | `InvalidArgument` |
|
||||
| `StreamAlarms` | No required fields — `alarm_filter_prefix` is optional. | — |
|
||||
| `QueryActiveAlarms` | No required fields — `alarm_filter_prefix` is optional. Not routed through `MxAccessGrpcRequestValidator`; the handler only null-checks the request. | — |
|
||||
|
||||
|
||||
@@ -351,6 +351,23 @@ native client correlating by handle has the identical ambiguity). The exposure
|
||||
is bounded: `statuses` is an enrichment field, and `Hresult`/`ProtocolStatus`
|
||||
stay acceptance-only regardless of which completion (if any) was attributed.
|
||||
|
||||
**The alarm ack writer narrows the window for acknowledgements.**
|
||||
`AlarmAckWriter` registers its own MXAccess server with its own
|
||||
`MxAccessWriteCompletionCache` and reuses one advised `AckMsg` item per alarm
|
||||
(least-recently-used, capped at 256). After an ack ends unconfirmed it marks the
|
||||
item dirty, and the next ack to that alarm removes and re-adds the item so the
|
||||
write waits on a fresh item handle the stale completion cannot match. This
|
||||
relies on MXAccess never reusing an item handle number within one
|
||||
registration, which a live probe confirmed (see "Item handle reuse" in
|
||||
[Alarm Probe Findings](./AlarmProbeFindings.md)). Every
|
||||
drop of an item (dirty re-add, eviction, or a failed `AddItem`/`Advise`/`Write`)
|
||||
also removes that handle's completion entry, and tearing down the registration
|
||||
removes every entry for its server handle, so the cache does not grow by one
|
||||
entry per dropped handle for the life of the worker. The writer records a
|
||||
completion only for an item handle it currently holds on its live
|
||||
registration, so a late `OnWriteComplete` for a dropped handle cannot recreate
|
||||
its entry.
|
||||
|
||||
## Live alarm watch-list replacement
|
||||
|
||||
An auto-mode `SubscribeAlarms` with `fallback_enabled = true` always builds a
|
||||
|
||||
+22
-18
@@ -140,8 +140,9 @@ one gateway-owned worker session subscribes the configured AVEVA alarm
|
||||
provider, caches the active-alarm set (reconciled periodically against the
|
||||
worker's snapshot), and fans it out to every client through the session-less
|
||||
`StreamAlarms` RPC — the stream opens with the current active-alarm snapshot,
|
||||
then streams live transitions. `AcknowledgeAlarm` is session-less and routes
|
||||
through the monitor. Clients never open a worker session to see alarms, and
|
||||
then streams live transitions. `AcknowledgeAlarm` is session-less, routes
|
||||
through the monitor, and carries the operator's credentials (see
|
||||
"Acknowledge" below). Clients never open a worker session to see alarms, and
|
||||
alarm monitoring is independent of client lifecycle; the monitor re-opens its
|
||||
session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see
|
||||
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
|
||||
@@ -221,20 +222,22 @@ forwards only events the worker emits and never synthesizes transitions
|
||||
itself. The synthesis rules are documented in
|
||||
`docs/AlarmClientDiscovery.md`.
|
||||
|
||||
**Acknowledge in subtag mode:** the ack-by-name path writes the operator
|
||||
comment to the alarm attribute's ack-comment subtag. The write performs the
|
||||
ack. If the attribute has no writable ack-comment subtag configured, the RPC
|
||||
returns `FailedPrecondition`. In alarm-manager mode, `AlarmAckByName` is
|
||||
used as before.
|
||||
|
||||
**Acceptance is not application:** the ack is forwarded to the provider, and a
|
||||
successful return means the provider accepted the call, not that the
|
||||
acknowledgement was applied. On galaxies whose alarmed attributes carry a
|
||||
non-free-access security classification, `AlarmAckByName` returns `rc=0` and the
|
||||
alarm stays `UNACK_ALM` — it carries an operator name, not an authenticated
|
||||
identity. (Observed on the probe rig; the mechanism is inferred.) Confirm an ack
|
||||
by the resulting transition, never by the return code. See
|
||||
`docs/AlarmProbeFindings.md`.
|
||||
**Acknowledge:** the gateway acknowledges an alarm by writing the operator's
|
||||
comment to the alarm attribute's `AckMsg` (subtag mode: the configured
|
||||
ack-comment subtag) over MXAccess, after `AuthenticateUser` as the requesting
|
||||
operator (`operator_user`, `operator_password`, optional `operator_domain`).
|
||||
The AVEVA runtime treats that write as the acknowledgement and historizes it
|
||||
with the authenticated account's name, domain and full name; the Historian's
|
||||
OperatorNode is the ack writer's MXAccess client name
|
||||
(`MxGateway:Alarms:AckClientName`). The worker waits a bounded time for
|
||||
`OnWriteComplete`: an OK status is reported as success, a non-OK status as
|
||||
`MxaccessFailure` with the status detail, and no completion as success with a
|
||||
diagnostic noting the write was sent but unconfirmed. An unknown alarm or one
|
||||
with no ack target (non-Galaxy provider, no ack-comment subtag) is reported as
|
||||
`InvalidRequest` with `hresult` -1. Alarm-client ack calls
|
||||
(`AlarmAckByName`/`AlarmAckByGUID`, the managed `AlarmClient`, WNAL) are not
|
||||
used: live tests showed a headless consumer's acks are accepted and silently
|
||||
dropped (see `docs/AlarmProbeFindings.md`).
|
||||
|
||||
**Degraded state visibility:** every subtag-mode transition carries
|
||||
`degraded = true` and `source_provider = ALARM_PROVIDER_MODE_SUBTAG` on the
|
||||
@@ -1028,8 +1031,9 @@ Internal worker IPC:
|
||||
- gateway validates worker executable path and version,
|
||||
- no secrets in command line when avoidable.
|
||||
|
||||
Credential-sensitive commands such as `AuthenticateUser` and `WriteSecured`
|
||||
must not log passwords or raw credential values.
|
||||
Credential-sensitive commands such as `AuthenticateUser`, `WriteSecured` and
|
||||
`AcknowledgeAlarm` (its `operator_password`) must not log passwords or raw
|
||||
credential values.
|
||||
|
||||
## Observability
|
||||
|
||||
|
||||
@@ -1169,7 +1169,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
|
||||
internal static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
|
||||
{
|
||||
parseError = null;
|
||||
if (string.IsNullOrWhiteSpace(request.AlarmFullReference))
|
||||
@@ -1180,6 +1180,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
string comment = request.Comment ?? string.Empty;
|
||||
string operatorUser = request.OperatorUser ?? string.Empty;
|
||||
string operatorPassword = request.OperatorPassword ?? string.Empty;
|
||||
string operatorDomain = request.OperatorDomain ?? string.Empty;
|
||||
|
||||
if (Guid.TryParse(request.AlarmFullReference, out Guid guid))
|
||||
{
|
||||
@@ -1192,7 +1194,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
Comment = comment,
|
||||
OperatorUser = operatorUser,
|
||||
OperatorNode = string.Empty,
|
||||
OperatorDomain = string.Empty,
|
||||
OperatorDomain = operatorDomain,
|
||||
OperatorPassword = operatorPassword,
|
||||
OperatorFullName = string.Empty,
|
||||
},
|
||||
};
|
||||
@@ -1211,7 +1214,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
Comment = comment,
|
||||
OperatorUser = operatorUser,
|
||||
OperatorNode = string.Empty,
|
||||
OperatorDomain = string.Empty,
|
||||
OperatorDomain = operatorDomain,
|
||||
OperatorPassword = operatorPassword,
|
||||
OperatorFullName = string.Empty,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,6 +73,13 @@ public sealed class AlarmsOptions
|
||||
/// </summary>
|
||||
public int MaxAlarmsPerFetch { get; init; } = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// MXAccess client name the alarm worker's ack writer registers with. The
|
||||
/// AVEVA Historian records it as each acknowledgement's OperatorNode.
|
||||
/// Conveyed to workers via MXGATEWAY_ALARM_ACK_CLIENT_NAME.
|
||||
/// </summary>
|
||||
public string AckClientName { get; init; } = "MxGateway";
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
|
||||
/// operating mode, failure-detection thresholds, discovery, and subtag
|
||||
|
||||
@@ -488,6 +488,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
$"MxGateway:Alarms:MaxAlarmsPerFetch must be between {MinimumMaxAlarmsPerFetch} and {MaximumMaxAlarmsPerFetch}.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.AckClientName)
|
||||
|| options.AckClientName.Length > 64
|
||||
|| options.AckClientName.Any(char.IsControl))
|
||||
{
|
||||
builder.Add("MxGateway:Alarms:AckClientName must be 1-64 printable characters.");
|
||||
}
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -12,7 +12,10 @@ public static class GatewayLogRedactor
|
||||
{
|
||||
"AuthenticateUser",
|
||||
"WriteSecured",
|
||||
"WriteSecured2"
|
||||
"WriteSecured2",
|
||||
// Carry operator_password.
|
||||
"AcknowledgeAlarm",
|
||||
"AcknowledgeAlarmByName"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -33,6 +33,14 @@ public sealed class GatewayLogRedactorSeam : ILogRedactor
|
||||
}
|
||||
|
||||
RedactCommandValue(properties);
|
||||
|
||||
foreach (string key in properties.Keys.ToList())
|
||||
{
|
||||
if (key.Contains("password", StringComparison.OrdinalIgnoreCase) && properties[key] is not null)
|
||||
{
|
||||
properties[key] = GatewayLogRedactor.RedactedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -197,6 +197,11 @@ public sealed class MxAccessGatewayService(
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required."));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.OperatorUser))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "operator_user is required."));
|
||||
}
|
||||
|
||||
return await alarmService.AcknowledgeAsync(request, context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -223,6 +223,8 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
|
||||
startInfo.Environment[WorkerLaunchEnvironment.MaxAlarmsPerFetch] =
|
||||
_alarmsOptions.MaxAlarmsPerFetch.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
startInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName] = _alarmsOptions.AckClientName;
|
||||
|
||||
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
|
||||
|
||||
return startInfo;
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
native 2.1.11; 2.1.12 patches it. Bumping Sqlite does not clear it. -->
|
||||
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<!-- Security pin: GHSA-846f-f764-76mx / GHSA-2fmv-rv2c-59x7. Microsoft.Data.SqlClient 6.0.2
|
||||
pulls the vulnerable System.Security.Cryptography.Pkcs 9.0.4; 10.0.13 patches it. -->
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
|
||||
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"DefaultArea": "",
|
||||
"ReconcileIntervalSeconds": 30,
|
||||
"PollIntervalMilliseconds": 500,
|
||||
"MaxAlarmsPerFetch": 1024
|
||||
"MaxAlarmsPerFetch": 1024,
|
||||
"AckClientName": "MxGateway"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
|
||||
|
||||
public sealed class GatewayAlarmMonitorAcknowledgeTests
|
||||
{
|
||||
/// <summary>A GUID-referenced ack carries the operator identity and password to the worker command.</summary>
|
||||
[Fact]
|
||||
public void BuildAcknowledgeCommand_GuidReference_ForwardsOperatorCredentials()
|
||||
{
|
||||
MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
|
||||
Comment = "checked",
|
||||
OperatorUser = "alice",
|
||||
OperatorDomain = "CORP",
|
||||
OperatorPassword = "pw-secret",
|
||||
}, out string? error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Equal(MxCommandKind.AcknowledgeAlarm, command!.Kind);
|
||||
Assert.Equal("alice", command.AcknowledgeAlarmCommand.OperatorUser);
|
||||
Assert.Equal("CORP", command.AcknowledgeAlarmCommand.OperatorDomain);
|
||||
Assert.Equal("pw-secret", command.AcknowledgeAlarmCommand.OperatorPassword);
|
||||
}
|
||||
|
||||
/// <summary>A name-referenced ack carries the operator identity and password to the by-name command.</summary>
|
||||
[Fact]
|
||||
public void BuildAcknowledgeCommand_NameReference_ForwardsOperatorCredentials()
|
||||
{
|
||||
MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
||||
OperatorUser = "alice",
|
||||
OperatorDomain = "CORP",
|
||||
OperatorPassword = "pw-secret",
|
||||
}, out _);
|
||||
|
||||
Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, command!.Kind);
|
||||
Assert.Equal("TestMachine_001.TestAlarm001", command.AcknowledgeAlarmByNameCommand.AlarmName);
|
||||
Assert.Equal("alice", command.AcknowledgeAlarmByNameCommand.OperatorUser);
|
||||
Assert.Equal("CORP", command.AcknowledgeAlarmByNameCommand.OperatorDomain);
|
||||
Assert.Equal("pw-secret", command.AcknowledgeAlarmByNameCommand.OperatorPassword);
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,45 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Tls = source.Tls,
|
||||
};
|
||||
|
||||
/// <summary>Verifies AckClientName defaults to "MxGateway" and passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WithDefaultAckClientName()
|
||||
{
|
||||
Assert.Equal("MxGateway", new AlarmsOptions().AckClientName);
|
||||
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator()
|
||||
.Validate(null, CloneWithAlarms(ValidOptions(), new AlarmsOptions()));
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies an empty, over-long, or control-character AckClientName fails validation.</summary>
|
||||
/// <param name="name">Client name under test.</param>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("bad\nname")]
|
||||
public void Validate_Fails_WhenAckClientNameInvalid(string name)
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator()
|
||||
.Validate(null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = name }));
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Alarms:AckClientName", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a 65-character AckClientName fails and a 64-character one passes.</summary>
|
||||
[Fact]
|
||||
public void Validate_AckClientNameLengthBoundary()
|
||||
{
|
||||
GatewayOptionsValidator validator = new();
|
||||
Assert.False(validator.Validate(
|
||||
null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = new string('a', 65) })).Succeeded);
|
||||
Assert.True(validator.Validate(
|
||||
null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = new string('a', 64) })).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the alarm poll cadence and per-fetch cap defaults pass validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap()
|
||||
|
||||
@@ -84,4 +84,23 @@ public sealed class GatewayLogRedactorSeamTests
|
||||
|
||||
Assert.False(props.ContainsKey("CommandValue"));
|
||||
}
|
||||
|
||||
/// <summary>Any property whose name contains "password" is masked regardless of casing.</summary>
|
||||
[Fact]
|
||||
public void Redact_PasswordNamedProperty_IsRedacted()
|
||||
{
|
||||
GatewayLogRedactorSeam redactor = new();
|
||||
Dictionary<string, object?> props = new()
|
||||
{
|
||||
["OperatorPassword"] = "pw-secret",
|
||||
["verify_user_PASSWORD"] = "pw-secret",
|
||||
["OperatorUser"] = "alice",
|
||||
};
|
||||
|
||||
redactor.Redact(props);
|
||||
|
||||
Assert.Equal("[redacted]", props["OperatorPassword"]);
|
||||
Assert.Equal("[redacted]", props["verify_user_PASSWORD"]);
|
||||
Assert.Equal("alice", props["OperatorUser"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ public sealed class GatewayLogRedactorTests
|
||||
[InlineData("AuthenticateUser")]
|
||||
[InlineData("WriteSecured")]
|
||||
[InlineData("WriteSecured2")]
|
||||
[InlineData("AcknowledgeAlarm")]
|
||||
[InlineData("AcknowledgeAlarmByName")]
|
||||
public void IsCredentialBearingCommand_IdentifiesSensitiveMxAccessCommands(string commandMethod)
|
||||
{
|
||||
Assert.True(GatewayLogRedactor.IsCredentialBearingCommand(commandMethod));
|
||||
|
||||
@@ -389,6 +389,22 @@ public sealed class MxAccessGatewayServiceTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies AcknowledgeAlarm rejects an empty operator_user.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarm_WithMissingOperatorUser_ThrowsInvalidArgument()
|
||||
{
|
||||
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await service.AcknowledgeAlarm(
|
||||
new AcknowledgeAlarmRequest { AlarmFullReference = "Galaxy!Area.Tag" },
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Contains("operator_user", exception.Status.Detail, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies AcknowledgeAlarm delegates a valid request to the alarm service.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
WorkerLaunchEnvironment.EventQueueCapacity,
|
||||
WorkerLaunchEnvironment.AlarmPollIntervalMilliseconds,
|
||||
WorkerLaunchEnvironment.MaxAlarmsPerFetch,
|
||||
WorkerLaunchEnvironment.AlarmAckClientName,
|
||||
];
|
||||
|
||||
/// <summary>Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable.</summary>
|
||||
@@ -75,6 +76,10 @@ public sealed class WorkerProcessLauncherTests
|
||||
"1024",
|
||||
processFactory.LastStartInfo.Environment[
|
||||
WorkerLaunchEnvironment.MaxAlarmsPerFetch]);
|
||||
Assert.Equal(
|
||||
"MxGateway",
|
||||
processFactory.LastStartInfo.Environment[
|
||||
WorkerLaunchEnvironment.AlarmAckClientName]);
|
||||
Assert.All(
|
||||
RequiredLaunchEnvironmentVariables,
|
||||
variable => Assert.True(
|
||||
@@ -86,6 +91,28 @@ public sealed class WorkerProcessLauncherTests
|
||||
Assert.Equal(0, metrics.GetSnapshot().WorkersRunning);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a configured AckClientName reaches the worker environment.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LaunchAsync_WithConfiguredAckClientName_ExportsItToTheWorkerEnvironment()
|
||||
{
|
||||
using TestDirectory directory = TestDirectory.Create();
|
||||
string executablePath = directory.CreateWorkerExecutable(machine: 0x014c);
|
||||
FakeWorkerProcessFactory processFactory = new(new FakeWorkerProcess(processId: 1234));
|
||||
WorkerProcessLauncher launcher = CreateLauncher(
|
||||
executablePath,
|
||||
processFactory,
|
||||
new SucceedingStartupProbe(),
|
||||
ackClientName: "Plant-Ack");
|
||||
|
||||
using WorkerProcessHandle handle = await launcher.LaunchAsync(CreateRequest());
|
||||
|
||||
Assert.NotNull(processFactory.LastStartInfo);
|
||||
Assert.Equal(
|
||||
"Plant-Ack",
|
||||
processFactory.LastStartInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a configured <see cref="WorkerOptions.EventQueueCapacity"/> — not the shipped
|
||||
/// default — is what reaches the worker, so the option is deployable without a worker rebuild.
|
||||
@@ -265,10 +292,12 @@ public sealed class WorkerProcessLauncherTests
|
||||
int startupTimeoutSeconds = 30,
|
||||
int startupProbeRetryAttempts = 3,
|
||||
int startupProbeRetryDelayMilliseconds = 250,
|
||||
int eventQueueCapacity = 10000)
|
||||
int eventQueueCapacity = 10000,
|
||||
string ackClientName = "MxGateway")
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Alarms = new AlarmsOptions { AckClientName = ackClientName },
|
||||
Worker = new WorkerOptions
|
||||
{
|
||||
ExecutablePath = executablePath,
|
||||
|
||||
@@ -84,7 +84,9 @@ public sealed class AlarmAckWriterTests
|
||||
public void Acknowledge_Twice_ReusesItemHandle()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
|
||||
|
||||
writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
||||
@@ -114,7 +116,9 @@ public sealed class AlarmAckWriterTests
|
||||
public void Acknowledge_WhenCachedWriteThrows_RejectsAndReAddsNextTime()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
|
||||
writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
server.WriteException = new COMException("x", unchecked((int)0x80004005));
|
||||
|
||||
@@ -141,6 +145,265 @@ public sealed class AlarmAckWriterTests
|
||||
Assert.Contains("Unregister", server.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenAuthenticateUserDisconnected_TearsDownAndRejects()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer
|
||||
{
|
||||
AuthenticateResult = 5,
|
||||
AuthenticateException = new COMException("gone pw-secret", unchecked((int)0x80010108)),
|
||||
};
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
|
||||
Assert.Equal(unchecked((int)0x80010108), result.NativeStatus);
|
||||
Assert.Contains("AuthenticateUser", result.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.Contains("0x80010108", result.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.Contains("Unregister", server.Operations);
|
||||
Assert.DoesNotContain(server.Operations, op => op.StartsWith("AddItem", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenAuthenticateUserThrowsArgumentException_ReturnsAuthenticationFailed()
|
||||
{
|
||||
// Live MXAccess raises E_INVALIDARG for a bad user or wrong password, which the
|
||||
// interop surfaces as ArgumentException rather than COMException.
|
||||
RecordingAckServer server = new RecordingAckServer
|
||||
{
|
||||
AuthenticateResult = 5,
|
||||
AuthenticateException = new ArgumentException("Value does not fall within the expected range. pw-secret"),
|
||||
};
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome);
|
||||
Assert.Equal(unchecked((int)0x80070057), result.NativeStatus);
|
||||
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain("Unregister", server.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenAuthenticateUserThrowsArgumentNullException_Propagates()
|
||||
{
|
||||
// A programming error is a bug, not an operator authentication failure.
|
||||
RecordingAckServer server = new RecordingAckServer
|
||||
{
|
||||
AuthenticateResult = 5,
|
||||
AuthenticateException = new ArgumentNullException("verifyUser"),
|
||||
};
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => writer.Acknowledge("T.A.AckMsg", "one", Alice()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenOwnCodeOutsideComCallThrowsArgumentException_Propagates()
|
||||
{
|
||||
// The pump runs between Advise and Write. An ArgumentException from it carries
|
||||
// the same default HRESULT (0x80070057) as an interop E_INVALIDARG, so only
|
||||
// catching at the COM call itself keeps it from being reported as WriteRejected.
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => throw new ArgumentException("bug"), TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
ArgumentException thrown = Assert.Throws<ArgumentException>(() => writer.Acknowledge("T.A.AckMsg", "one", Alice()));
|
||||
Assert.Equal("bug", thrown.Message);
|
||||
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenWriteThrowsArgumentOutOfRangeException_Propagates()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new ArgumentOutOfRangeException("value") };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => writer.Acknowledge("T.A.AckMsg", "one", Alice()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenOperatorUserNameIsNull_ThrowsBeforeAuthenticating()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
Assert.Throws<ArgumentException>(() => writer.Acknowledge("T.A.AckMsg", "one", new AlarmAckOperator { UserName = null! }));
|
||||
Assert.DoesNotContain(server.Operations, op => op.StartsWith("AuthenticateUser", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenWriteThrowsArgumentException_RejectsAndDropsItem()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new ArgumentException("bad") };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
|
||||
Assert.Equal(unchecked((int)0x80070057), result.NativeStatus);
|
||||
Assert.Contains("Write", result.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.Equal(new[] { 100 }, server.RemovedItemHandles);
|
||||
Assert.DoesNotContain("Unregister", server.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_PastCacheCap_EvictsLeastRecentlyUsedItem()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(1), "MxGateway", maxCachedItems: 2);
|
||||
|
||||
writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100
|
||||
writer.Acknowledge("B.X.AckMsg", "2", Alice()); // handle 101
|
||||
writer.Acknowledge("C.X.AckMsg", "3", Alice()); // handle 102, evicts A (100)
|
||||
|
||||
Assert.Equal(new[] { 100 }, server.RemovedItemHandles);
|
||||
Assert.Equal(3, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_ReusedItem_IsMostRecentlyUsedForEviction()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway", maxCachedItems: 2);
|
||||
|
||||
writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100
|
||||
writer.Acknowledge("B.X.AckMsg", "2", Alice()); // handle 101
|
||||
writer.Acknowledge("A.X.AckMsg", "3", Alice()); // reuse 100, now most recent
|
||||
writer.Acknowledge("C.X.AckMsg", "4", Alice()); // handle 102, evicts B (101)
|
||||
|
||||
Assert.Equal(new[] { 101 }, server.RemovedItemHandles);
|
||||
Assert.Equal(3, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
||||
Assert.Contains("UnAdvise", server.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_AfterUnconfirmed_UsesFreshHandleSoStaleCompletionIsNotCredited()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
|
||||
|
||||
AlarmAckResult first = writer.Acknowledge("T.A.AckMsg", "one", Alice()); // handle 100, no completion
|
||||
// The first write's late OnWriteComplete (handle 100) lands while the
|
||||
// second write is in flight; the second write's own completion never comes.
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, 100, Rows(MxStatusCategory.Ok, 0));
|
||||
AlarmAckResult second = writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.Unconfirmed, first.Outcome);
|
||||
Assert.Equal(AlarmAckOutcome.Unconfirmed, second.Outcome);
|
||||
Assert.Contains(100, server.RemovedItemHandles);
|
||||
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
||||
Assert.Equal(new[] { 100, 101 }, server.WrittenItemHandles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_AfterConfirmed_ReusesHandle()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
|
||||
|
||||
writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
||||
|
||||
Assert.Equal(new[] { 100, 100 }, server.WrittenItemHandles);
|
||||
Assert.Empty(server.RemovedItemHandles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_AfterDirtyReAdd_ForgetsDroppedHandleCompletion()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromMilliseconds(20), "MxGateway");
|
||||
|
||||
writer.Acknowledge("T.A.AckMsg", "one", Alice()); // handle 100, Unconfirmed
|
||||
cache.Record(7, 100, Rows(MxStatusCategory.Ok, 0)); // its late completion
|
||||
Assert.Equal(1, cache.Count);
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
||||
|
||||
AlarmAckResult second = writer.Acknowledge("T.A.AckMsg", "two", Alice()); // drops 100, adds 101
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.Applied, second.Outcome);
|
||||
Assert.Equal(1, cache.Count);
|
||||
Assert.Equal(0UL, cache.CurrentVersion(7, 100));
|
||||
Assert.Equal(1UL, cache.CurrentVersion(7, 101));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateCompletionForDroppedHandle_DoesNotRecreateCacheEntry()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromMilliseconds(20), "MxGateway");
|
||||
writer.Acknowledge("T.A.AckMsg", "one", Alice()); // handle 100, Unconfirmed
|
||||
server.OnWrite = (handle, item) => writer.RecordWriteCompletion(handle, item, Rows(MxStatusCategory.Ok, 0));
|
||||
writer.Acknowledge("T.A.AckMsg", "two", Alice()); // drops 100, adds 101, applied
|
||||
|
||||
writer.RecordWriteCompletion(7, 100, Rows(MxStatusCategory.Ok, 0)); // 100's late completion
|
||||
writer.RecordWriteCompletion(8, 101, Rows(MxStatusCategory.Ok, 0)); // another registration
|
||||
|
||||
Assert.Equal(1, cache.Count);
|
||||
Assert.Equal(0UL, cache.CurrentVersion(7, 100));
|
||||
Assert.Equal(1UL, cache.CurrentVersion(7, 101));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_PastCacheCap_ForgetsEvictedHandleCompletion()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway", maxCachedItems: 2);
|
||||
|
||||
writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100
|
||||
writer.Acknowledge("B.X.AckMsg", "2", Alice()); // handle 101
|
||||
Assert.Equal(2, cache.Count);
|
||||
writer.Acknowledge("C.X.AckMsg", "3", Alice()); // handle 102, evicts A (100)
|
||||
|
||||
Assert.Equal(2, cache.Count);
|
||||
Assert.Equal(0UL, cache.CurrentVersion(7, 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_ForgetsServerCompletions()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
||||
AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
|
||||
writer.Acknowledge("A.X.AckMsg", "1", Alice());
|
||||
writer.Acknowledge("B.X.AckMsg", "2", Alice());
|
||||
cache.Record(8, 100, Rows(MxStatusCategory.Ok, 0)); // another server's entry survives
|
||||
|
||||
writer.Dispose();
|
||||
|
||||
Assert.Equal(1, cache.Count);
|
||||
Assert.Equal(1UL, cache.CurrentVersion(8, 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenWriteDisconnected_ForgetsServerCompletions()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
|
||||
writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100 recorded
|
||||
server.WriteException = new COMException("x", unchecked((int)0x80010108));
|
||||
|
||||
writer.Acknowledge("A.X.AckMsg", "2", Alice());
|
||||
|
||||
Assert.Contains("Unregister", server.Operations);
|
||||
Assert.Equal(0, cache.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenNonOkCompletionHasNoDetail_ReturnsMinusOne()
|
||||
{
|
||||
@@ -180,6 +443,10 @@ internal sealed class RecordingAckServer : IMxAccessServer
|
||||
|
||||
public Action<int, int>? OnWrite { get; set; }
|
||||
|
||||
public List<int> RemovedItemHandles { get; } = new();
|
||||
|
||||
public List<int> WrittenItemHandles { get; } = new();
|
||||
|
||||
public int Register(string clientName)
|
||||
{
|
||||
Operations.Add("Register:" + clientName);
|
||||
@@ -196,7 +463,11 @@ internal sealed class RecordingAckServer : IMxAccessServer
|
||||
|
||||
public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition);
|
||||
|
||||
public void RemoveItem(int serverHandle, int itemHandle) => Operations.Add("RemoveItem");
|
||||
public void RemoveItem(int serverHandle, int itemHandle)
|
||||
{
|
||||
Operations.Add("RemoveItem");
|
||||
RemovedItemHandles.Add(itemHandle);
|
||||
}
|
||||
|
||||
public void Advise(int serverHandle, int itemHandle)
|
||||
{
|
||||
@@ -216,6 +487,7 @@ internal sealed class RecordingAckServer : IMxAccessServer
|
||||
public void Write(int serverHandle, int itemHandle, object? value, int userId)
|
||||
{
|
||||
Operations.Add("Write:" + value + ":" + userId);
|
||||
WrittenItemHandles.Add(itemHandle);
|
||||
if (WriteException is not null)
|
||||
{
|
||||
throw WriteException;
|
||||
|
||||
@@ -183,6 +183,39 @@ public sealed class MxAccessWriteCompletionCacheTests
|
||||
Assert.Equal(77, Assert.Single(statuses).Detail);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Remove forgets only the given handle pair.</summary>
|
||||
[Fact]
|
||||
public void Remove_ForgetsOnlyThatHandlePair()
|
||||
{
|
||||
MxAccessWriteCompletionCache cache = new();
|
||||
cache.Record(7, 21, BuildStatuses(detail: 1));
|
||||
cache.Record(7, 22, BuildStatuses(detail: 2));
|
||||
|
||||
cache.Remove(7, 21);
|
||||
cache.Remove(7, 99); // unknown pair is a no-op
|
||||
|
||||
Assert.Equal(1, cache.Count);
|
||||
Assert.Equal(0UL, cache.CurrentVersion(7, 21));
|
||||
Assert.Equal(1UL, cache.CurrentVersion(7, 22));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RemoveServer forgets every pair of that server and no other.</summary>
|
||||
[Fact]
|
||||
public void RemoveServer_ForgetsOnlyThatServersPairs()
|
||||
{
|
||||
MxAccessWriteCompletionCache cache = new();
|
||||
cache.Record(7, 21, BuildStatuses(detail: 1));
|
||||
cache.Record(7, -5, BuildStatuses(detail: 2));
|
||||
cache.Record(8, 21, BuildStatuses(detail: 3));
|
||||
cache.Record(-7, 21, BuildStatuses(detail: 4));
|
||||
|
||||
cache.RemoveServer(7);
|
||||
|
||||
Assert.Equal(2, cache.Count);
|
||||
Assert.Equal(1UL, cache.CurrentVersion(8, 21));
|
||||
Assert.Equal(1UL, cache.CurrentVersion(-7, 21));
|
||||
}
|
||||
|
||||
private static RepeatedField<MxStatusProxy> BuildStatuses(int detail)
|
||||
{
|
||||
return new RepeatedField<MxStatusProxy>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// ItemHandleReuseProbeTests.cs
|
||||
//
|
||||
// Probe for issue #166: does MXAccess hand out a new item handle number when the
|
||||
// same item is removed and re-added on one LMXProxyServer registration? The
|
||||
// AlarmAckWriter's Unconfirmed mitigation (drop the item, re-add it, wait on the
|
||||
// fresh handle) relies on the number changing, because OnWriteComplete carries
|
||||
// only the (server, item) handle pair.
|
||||
//
|
||||
// The probe only adds, advises and removes items. It never writes, so it does
|
||||
// not acknowledge the alarm or create Historian rows.
|
||||
//
|
||||
// HOW TO RUN (dev rig with MXAccess installed and the DEV Galaxy running):
|
||||
// 1. Remove (or set to null) the Skip parameter on the [Fact] below.
|
||||
// 2. dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj
|
||||
// -p:Platform=x86 --filter FullyQualifiedName~ItemHandleReuseProbeTests
|
||||
// --logger "console;verbosity=detailed"
|
||||
// 3. Read the logged handles. Results are recorded in docs/AlarmProbeFindings.md.
|
||||
//
|
||||
// RESULT (windev, 2026-10-20): within one registration item handles increase
|
||||
// monotonically and are never reused (1..15 across add/remove/re-add of the same
|
||||
// item, with and without Advise, interleaved with another item, and while held
|
||||
// twice). Item numbering restarts at 1 after Unregister + Register, and a fresh
|
||||
// COM object is handed server handle 1 again.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using ArchestrA.MxAccess;
|
||||
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Live probe that logs the item handles MXAccess returns when the same
|
||||
/// <c>AckMsg</c> item is removed and re-added, to decide whether a late
|
||||
/// <c>OnWriteComplete</c> for a dropped handle could match a re-added one.
|
||||
/// </summary>
|
||||
public sealed class ItemHandleReuseProbeTests
|
||||
{
|
||||
private const string AckItem = "TestMachine_001.TestAlarm001.AckMsg";
|
||||
private const string OtherItem = "TestMachine_001.TestAlarm001.Acked";
|
||||
private const int Rounds = 5;
|
||||
|
||||
private readonly ITestOutputHelper output;
|
||||
private readonly List<string> log = new List<string>();
|
||||
|
||||
// Item handles returned on the first registration, in the order they were handed out.
|
||||
private readonly List<int> firstRegistrationHandles = new List<int>();
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ItemHandleReuseProbeTests"/> class.</summary>
|
||||
/// <param name="output">Test output helper for logging.</param>
|
||||
public ItemHandleReuseProbeTests(ITestOutputHelper output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
/// <summary>Adds, removes and re-adds the AckMsg item on a fresh registration and logs every handle.</summary>
|
||||
[Fact(Skip = "Live dev-rig probe (issue #166) — flip Skip=null on a host with MXAccess and the DEV Galaxy running.")]
|
||||
public void RemoveThenReAdd_LogsItemHandles()
|
||||
{
|
||||
Exception? threadException = null;
|
||||
Thread thread = new Thread(() =>
|
||||
{
|
||||
try { RunProbe(); }
|
||||
catch (Exception ex) { threadException = ex; }
|
||||
});
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
foreach (string line in log)
|
||||
{
|
||||
output.WriteLine(line);
|
||||
}
|
||||
|
||||
if (threadException is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Probe failed: " + threadException.Message, threadException);
|
||||
}
|
||||
|
||||
// The ack writer's drop-and-re-add mitigation depends on this: within one
|
||||
// registration a handle number is never handed out twice.
|
||||
Assert.Equal((2 * Rounds) + 5, firstRegistrationHandles.Count);
|
||||
for (int i = 1; i < firstRegistrationHandles.Count; i++)
|
||||
{
|
||||
Assert.True(
|
||||
firstRegistrationHandles[i] > firstRegistrationHandles[i - 1],
|
||||
$"Item handle {firstRegistrationHandles[i]} did not increase past {firstRegistrationHandles[i - 1]}; MXAccess reused or reordered item handles within one registration.");
|
||||
}
|
||||
}
|
||||
|
||||
private void RunProbe()
|
||||
{
|
||||
LMXProxyServerClass com = new LMXProxyServerClass();
|
||||
try
|
||||
{
|
||||
IMxAccessServer mx = new MxAccessComServer(com);
|
||||
int server = mx.Register("MxGatewayProbe166");
|
||||
Log($"Register -> server handle {server}");
|
||||
|
||||
// A: the AlarmAckWriter drop path (Advise, then UnAdvise + RemoveItem).
|
||||
for (int i = 0; i < Rounds; i++)
|
||||
{
|
||||
int h = Held(mx.AddItem(server, AckItem));
|
||||
mx.Advise(server, h);
|
||||
Log($"A{i}: AddItem+Advise {AckItem} -> {h}");
|
||||
mx.UnAdvise(server, h);
|
||||
mx.RemoveItem(server, h);
|
||||
Log($"A{i}: UnAdvise+RemoveItem {h}");
|
||||
}
|
||||
|
||||
// B: RemoveItem without Advise.
|
||||
for (int i = 0; i < Rounds; i++)
|
||||
{
|
||||
int h = Held(mx.AddItem(server, AckItem));
|
||||
Log($"B{i}: AddItem {AckItem} -> {h}");
|
||||
mx.RemoveItem(server, h);
|
||||
Log($"B{i}: RemoveItem {h}");
|
||||
}
|
||||
|
||||
// C: interleave another item to see whether a freed number is handed to any item.
|
||||
int c1 = Held(mx.AddItem(server, AckItem));
|
||||
Log($"C: AddItem {AckItem} -> {c1}");
|
||||
mx.RemoveItem(server, c1);
|
||||
Log($"C: RemoveItem {c1}");
|
||||
int c2 = Held(mx.AddItem(server, OtherItem));
|
||||
Log($"C: AddItem {OtherItem} -> {c2}");
|
||||
int c3 = Held(mx.AddItem(server, AckItem));
|
||||
Log($"C: AddItem {AckItem} -> {c3}");
|
||||
mx.RemoveItem(server, c2);
|
||||
mx.RemoveItem(server, c3);
|
||||
Log($"C: RemoveItem {c2}, {c3}");
|
||||
|
||||
// D: the same item added twice while the first is still held.
|
||||
int d1 = Held(mx.AddItem(server, AckItem));
|
||||
int d2 = Held(mx.AddItem(server, AckItem));
|
||||
Log($"D: AddItem {AckItem} twice while held -> {d1}, {d2}");
|
||||
mx.RemoveItem(server, d1);
|
||||
mx.RemoveItem(server, d2);
|
||||
|
||||
mx.Unregister(server);
|
||||
Log($"Unregister {server}");
|
||||
|
||||
// E: the TearDownServer path: re-register on the same COM object and add again.
|
||||
int server2 = mx.Register("MxGatewayProbe166");
|
||||
int e1 = mx.AddItem(server2, AckItem);
|
||||
Log($"E: re-Register -> server handle {server2}; AddItem {AckItem} -> {e1}");
|
||||
mx.RemoveItem(server2, e1);
|
||||
mx.Unregister(server2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(com);
|
||||
}
|
||||
|
||||
// F: a fresh COM object, as AlarmAckWriter creates after a teardown.
|
||||
LMXProxyServerClass com2 = new LMXProxyServerClass();
|
||||
try
|
||||
{
|
||||
IMxAccessServer mx2 = new MxAccessComServer(com2);
|
||||
int server3 = mx2.Register("MxGatewayProbe166");
|
||||
int f1 = mx2.AddItem(server3, AckItem);
|
||||
Log($"F: new COM object Register -> server handle {server3}; AddItem {AckItem} -> {f1}");
|
||||
mx2.RemoveItem(server3, f1);
|
||||
mx2.Unregister(server3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(com2);
|
||||
}
|
||||
}
|
||||
|
||||
private int Held(int itemHandle)
|
||||
{
|
||||
firstRegistrationHandles.Add(itemHandle);
|
||||
return itemHandle;
|
||||
}
|
||||
|
||||
private void Log(string line)
|
||||
{
|
||||
log.Add(DateTime.Now.ToString("HH:mm:ss.fff") + " " + line);
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public sealed class WorkerPipeSession
|
||||
options,
|
||||
() => Process.GetCurrentProcess().Id,
|
||||
new WorkerPipeSessionOptions(),
|
||||
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
|
||||
() => new MxAccessStaSession((eq, affinity, comFactory, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump)),
|
||||
logger,
|
||||
stream)
|
||||
{
|
||||
@@ -98,7 +98,7 @@ public sealed class WorkerPipeSession
|
||||
options,
|
||||
processIdProvider,
|
||||
new WorkerPipeSessionOptions(),
|
||||
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
|
||||
() => new MxAccessStaSession((eq, affinity, comFactory, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump)),
|
||||
logger: null)
|
||||
{
|
||||
}
|
||||
@@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession
|
||||
// parameterless CompleteStartupHandshakeAsync is used without a
|
||||
// prior factory call.
|
||||
_runtimeSession ??= new MxAccessStaSession(
|
||||
(eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null));
|
||||
(eq, affinity, comFactory, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump));
|
||||
IWorkerRuntimeSession session = _runtimeSession;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -26,13 +26,30 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
/// <summary>Default MXAccess client name when none is configured.</summary>
|
||||
public const string DefaultClientName = "MxGateway";
|
||||
|
||||
/// <summary>
|
||||
/// Default cap on cached ack item handles. Each cached item holds an
|
||||
/// advised MXAccess item for the worker's life, so the cache is bounded
|
||||
/// (least-recently-used eviction) rather than growing with every
|
||||
/// distinct alarm ever acknowledged.
|
||||
/// </summary>
|
||||
public const int DefaultMaxCachedItems = 256;
|
||||
|
||||
private readonly IMxAccessComObjectFactory? factory;
|
||||
private readonly MxAccessWriteCompletionCache completionCache;
|
||||
private readonly Action pumpStep;
|
||||
private readonly TimeSpan completionTimeout;
|
||||
private readonly string clientName;
|
||||
private readonly MxStatusProxyConverter statusConverter = new MxStatusProxyConverter();
|
||||
private readonly Dictionary<string, int> itemHandles = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly int maxCachedItems;
|
||||
private readonly Dictionary<string, LinkedListNode<AckItem>> itemHandles = new Dictionary<string, LinkedListNode<AckItem>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Most-recently-used first; the tail is the eviction candidate.
|
||||
private readonly LinkedList<AckItem> itemLru = new LinkedList<AckItem>();
|
||||
|
||||
// Item handles currently added on this registration. A completion for any
|
||||
// other handle (a dropped one whose OnWriteComplete arrives late) is ignored
|
||||
// so it cannot recreate a completion-cache entry for a dead handle.
|
||||
private readonly HashSet<int> heldItemHandles = new HashSet<int>();
|
||||
private object? comObject;
|
||||
private LMXProxyServerClass? comEventSource;
|
||||
private IMxAccessServer? server;
|
||||
@@ -54,6 +71,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
|
||||
this.completionTimeout = completionTimeout;
|
||||
this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!;
|
||||
maxCachedItems = DefaultMaxCachedItems;
|
||||
completionCache = new MxAccessWriteCompletionCache();
|
||||
}
|
||||
|
||||
@@ -63,8 +81,11 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
MxAccessWriteCompletionCache completionCache,
|
||||
Action pumpStep,
|
||||
TimeSpan completionTimeout,
|
||||
string clientName)
|
||||
string clientName,
|
||||
int maxCachedItems = DefaultMaxCachedItems)
|
||||
{
|
||||
if (maxCachedItems < 1) throw new ArgumentOutOfRangeException(nameof(maxCachedItems));
|
||||
this.maxCachedItems = maxCachedItems;
|
||||
this.server = server ?? throw new ArgumentNullException(nameof(server));
|
||||
this.completionCache = completionCache ?? throw new ArgumentNullException(nameof(completionCache));
|
||||
this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
|
||||
@@ -88,19 +109,36 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress));
|
||||
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
|
||||
|
||||
if (ackOperator.UserName is null) throw new ArgumentException("Operator user name is required.", nameof(ackOperator));
|
||||
string verifyUser = ackOperator.VerifyUser;
|
||||
string password = ackOperator.Password ?? string.Empty;
|
||||
string commentValue = comment ?? string.Empty;
|
||||
|
||||
IMxAccessServer mx = EnsureRegistered();
|
||||
|
||||
int userId;
|
||||
try
|
||||
{
|
||||
userId = mx.AuthenticateUser(serverHandle, ackOperator.VerifyUser, ackOperator.Password ?? string.Empty);
|
||||
userId = Interop("AuthenticateUser", () => mx.AuthenticateUser(serverHandle, verifyUser, password));
|
||||
}
|
||||
catch (COMException ex)
|
||||
catch (AckInteropException ex)
|
||||
{
|
||||
int authHresult = ex.HResult;
|
||||
if (IsServerGone(authHresult))
|
||||
{
|
||||
// The registration is dead, not the credentials: tear down so the
|
||||
// next ack re-registers, and report it as a write-path failure.
|
||||
TearDownServer();
|
||||
return AlarmAckResult.Create(
|
||||
AlarmAckOutcome.WriteRejected,
|
||||
authHresult,
|
||||
$"AckMsg AuthenticateUser failed (HRESULT 0x{authHresult:X8}).");
|
||||
}
|
||||
|
||||
return AlarmAckResult.Create(
|
||||
AlarmAckOutcome.AuthenticationFailed,
|
||||
ex.HResult,
|
||||
$"Operator authentication failed for '{ackOperator.VerifyUser}' (HRESULT 0x{ex.HResult:X8}).");
|
||||
$"Operator authentication failed for '{verifyUser}' (HRESULT 0x{ex.HResult:X8}).");
|
||||
}
|
||||
|
||||
if (userId == 0)
|
||||
@@ -108,36 +146,59 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
return AlarmAckResult.Create(
|
||||
AlarmAckOutcome.AuthenticationFailed,
|
||||
-1,
|
||||
$"Operator authentication failed for '{ackOperator.VerifyUser}' (no user id returned).");
|
||||
$"Operator authentication failed for '{verifyUser}' (no user id returned).");
|
||||
}
|
||||
|
||||
ulong baselineVersion = 0;
|
||||
int itemHandle = 0;
|
||||
bool cached = itemHandles.TryGetValue(itemAddress, out itemHandle);
|
||||
string step = "AddItem";
|
||||
LinkedListNode<AckItem>? node;
|
||||
bool cached = itemHandles.TryGetValue(itemAddress, out node);
|
||||
try
|
||||
{
|
||||
if (!cached)
|
||||
if (cached && node!.Value.Dirty)
|
||||
{
|
||||
itemHandle = mx.AddItem(serverHandle, itemAddress);
|
||||
itemHandles[itemAddress] = itemHandle;
|
||||
step = "Advise";
|
||||
// The previous ack to this item ended Unconfirmed, so its
|
||||
// OnWriteComplete may still arrive. Completions are keyed by
|
||||
// (server, item) handle with no per-write id, so a late one would
|
||||
// be credited to this write. Drop the item and re-add it so this
|
||||
// write waits on a fresh handle the stale completion cannot match.
|
||||
// That holds because MXAccess never reuses an item handle number
|
||||
// within one registration: the live probe (ItemHandleReuseProbeTests,
|
||||
// windev 2026-10-20) saw strictly increasing handles across repeated
|
||||
// remove/re-add of the same item. Numbering restarts only on a new
|
||||
// registration, and TearDownServer detaches this writer's
|
||||
// OnWriteComplete handler and clears the server's completion entries
|
||||
// first, so the old registration's completions cannot reach it.
|
||||
DropItem(mx, node);
|
||||
cached = false;
|
||||
}
|
||||
|
||||
if (cached)
|
||||
{
|
||||
itemHandle = node!.Value.Handle;
|
||||
itemLru.Remove(node);
|
||||
itemLru.AddFirst(node);
|
||||
}
|
||||
else
|
||||
{
|
||||
itemHandle = Interop("AddItem", () => mx.AddItem(serverHandle, itemAddress));
|
||||
node = CacheItem(mx, itemAddress, itemHandle);
|
||||
int added = itemHandle;
|
||||
// MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG.
|
||||
mx.Advise(serverHandle, itemHandle);
|
||||
Interop("Advise", () => mx.Advise(serverHandle, added));
|
||||
}
|
||||
|
||||
pumpStep();
|
||||
step = "Write";
|
||||
baselineVersion = completionCache.CurrentVersion(serverHandle, itemHandle);
|
||||
mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId);
|
||||
int target = itemHandle;
|
||||
Interop("Write", () => mx.Write(serverHandle, target, commentValue, userId));
|
||||
}
|
||||
catch (COMException ex)
|
||||
catch (AckInteropException ex)
|
||||
{
|
||||
int hresult = ex.HResult;
|
||||
if (itemHandles.Remove(itemAddress))
|
||||
if (itemHandles.TryGetValue(itemAddress, out LinkedListNode<AckItem>? failed))
|
||||
{
|
||||
try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ }
|
||||
try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ }
|
||||
DropItem(mx, failed!);
|
||||
}
|
||||
|
||||
if (IsServerGone(hresult))
|
||||
@@ -149,7 +210,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
return AlarmAckResult.Create(
|
||||
AlarmAckOutcome.WriteRejected,
|
||||
hresult,
|
||||
$"AckMsg {step} failed (HRESULT 0x{hresult:X8}).");
|
||||
$"AckMsg {ex.Step} failed (HRESULT 0x{hresult:X8}).");
|
||||
}
|
||||
|
||||
ulong baseline = baselineVersion;
|
||||
@@ -162,6 +223,7 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
pumpStep,
|
||||
out RepeatedField<MxStatusProxy> statuses))
|
||||
{
|
||||
node!.Value.Dirty = true;
|
||||
return AlarmAckResult.Create(
|
||||
AlarmAckOutcome.Unconfirmed,
|
||||
0,
|
||||
@@ -192,6 +254,54 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
TearDownServer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one MXAccess COM call and converts a failure the interop raised
|
||||
/// for its HRESULT into <see cref="AckInteropException"/>. Only the COM
|
||||
/// call itself runs inside the filter, so an exception thrown by this
|
||||
/// writer's own code (argument checks, caching, the pump) propagates as
|
||||
/// a bug instead of being reported as an MXAccess failure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The runtime maps well-known HRESULTs to typed exceptions and the rest
|
||||
/// to COMException; each carries the HRESULT in Exception.HResult. The
|
||||
/// ArgumentException arm is needed because a wrong password or unknown
|
||||
/// user makes <c>AuthenticateUser</c> throw ArgumentException with
|
||||
/// E_INVALIDARG (0x80070057), observed live on windev on 2026-10-19.
|
||||
/// ArgumentNullException and ArgumentOutOfRangeException are excluded:
|
||||
/// the interop does not raise them for an HRESULT, so one escaping the
|
||||
/// call is an argument bug in the adapter, not an MXAccess failure.
|
||||
/// </remarks>
|
||||
private static T Interop<T>(string step, Func<T> call)
|
||||
{
|
||||
try
|
||||
{
|
||||
return call();
|
||||
}
|
||||
catch (Exception ex) when (IsInteropFailure(ex))
|
||||
{
|
||||
throw new AckInteropException(step, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Interop(string step, Action call)
|
||||
{
|
||||
try
|
||||
{
|
||||
call();
|
||||
}
|
||||
catch (Exception ex) when (IsInteropFailure(ex))
|
||||
{
|
||||
throw new AckInteropException(step, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInteropFailure(Exception ex) =>
|
||||
ex is ExternalException
|
||||
|| ex is UnauthorizedAccessException
|
||||
|| (ex is ArgumentException
|
||||
&& ex is not ArgumentNullException
|
||||
&& ex is not ArgumentOutOfRangeException);
|
||||
|
||||
private static bool IsServerGone(int hresult) =>
|
||||
hresult == unchecked((int)0x80010108) // RPC_E_DISCONNECTED
|
||||
|| hresult == unchecked((int)0x800706BA) // RPC_S_SERVER_UNAVAILABLE
|
||||
@@ -208,16 +318,21 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
IMxAccessServer? mx = server;
|
||||
if (mx is not null)
|
||||
{
|
||||
foreach (int itemHandle in itemHandles.Values)
|
||||
foreach (AckItem item in itemLru)
|
||||
{
|
||||
try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ }
|
||||
try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ }
|
||||
try { mx.UnAdvise(serverHandle, item.Handle); } catch { /* best effort */ }
|
||||
try { mx.RemoveItem(serverHandle, item.Handle); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
try { mx.Unregister(serverHandle); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// The registration is gone, so no completion can arrive for its handles.
|
||||
completionCache.RemoveServer(serverHandle);
|
||||
|
||||
itemHandles.Clear();
|
||||
itemLru.Clear();
|
||||
heldItemHandles.Clear();
|
||||
server = null;
|
||||
comEventSource = null;
|
||||
object? toRelease = comObject;
|
||||
@@ -228,6 +343,34 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Caches a newly added item as most recently used, evicting the least recently used past the cap.</summary>
|
||||
private LinkedListNode<AckItem> CacheItem(IMxAccessServer mx, string itemAddress, int itemHandle)
|
||||
{
|
||||
while (itemLru.Count >= maxCachedItems && itemLru.Last is { } leastRecent)
|
||||
{
|
||||
DropItem(mx, leastRecent);
|
||||
}
|
||||
|
||||
LinkedListNode<AckItem> node = itemLru.AddFirst(new AckItem(itemAddress, itemHandle));
|
||||
itemHandles[itemAddress] = node;
|
||||
heldItemHandles.Add(itemHandle);
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <summary>Removes a cached item and best-effort releases its MXAccess item.</summary>
|
||||
private void DropItem(IMxAccessServer mx, LinkedListNode<AckItem> node)
|
||||
{
|
||||
itemHandles.Remove(node.Value.Address);
|
||||
itemLru.Remove(node);
|
||||
heldItemHandles.Remove(node.Value.Handle);
|
||||
try { mx.UnAdvise(serverHandle, node.Value.Handle); } catch { /* best effort */ }
|
||||
try { mx.RemoveItem(serverHandle, node.Value.Handle); } catch { /* best effort */ }
|
||||
|
||||
// Without this the completion cache keeps one entry per dropped handle
|
||||
// (each Unconfirmed re-add and each eviction) for the worker's life.
|
||||
completionCache.Remove(serverHandle, node.Value.Handle);
|
||||
}
|
||||
|
||||
private IMxAccessServer EnsureRegistered()
|
||||
{
|
||||
if (server is not null) return server;
|
||||
@@ -274,6 +417,53 @@ public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
{
|
||||
RepeatedField<MxStatusProxy> rows = new RepeatedField<MxStatusProxy>();
|
||||
rows.AddRange(statusConverter.ConvertMany(pVars));
|
||||
completionCache.Record(hLMXServerHandle, phItemHandle, rows);
|
||||
RecordWriteCompletion(hLMXServerHandle, phItemHandle, rows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a write completion only for an item handle this writer
|
||||
/// currently holds on its live registration. A late completion for a
|
||||
/// dropped handle would otherwise recreate the cache entry that
|
||||
/// <see cref="DropItem"/> removed. Internal so tests can stand in for
|
||||
/// the COM event.
|
||||
/// </summary>
|
||||
internal void RecordWriteCompletion(int completionServerHandle, int itemHandle, RepeatedField<MxStatusProxy> rows)
|
||||
{
|
||||
if (server is null || completionServerHandle != serverHandle || !heldItemHandles.Contains(itemHandle))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
completionCache.Record(completionServerHandle, itemHandle, rows);
|
||||
}
|
||||
|
||||
/// <summary>An MXAccess failure raised by one COM call, carrying that call's HRESULT.</summary>
|
||||
private sealed class AckInteropException : Exception
|
||||
{
|
||||
public AckInteropException(string step, Exception inner)
|
||||
: base("MXAccess " + step + " failed.", inner)
|
||||
{
|
||||
Step = step;
|
||||
HResult = inner.HResult;
|
||||
}
|
||||
|
||||
public string Step { get; }
|
||||
}
|
||||
|
||||
/// <summary>A cached ack item. Plain class (not a record) for the net48 worker target.</summary>
|
||||
private sealed class AckItem
|
||||
{
|
||||
public AckItem(string address, int handle)
|
||||
{
|
||||
Address = address;
|
||||
Handle = handle;
|
||||
}
|
||||
|
||||
public string Address { get; }
|
||||
|
||||
public int Handle { get; }
|
||||
|
||||
/// <summary>Set when the last write to this handle ended Unconfirmed; forces a fresh handle next time.</summary>
|
||||
public bool Dirty { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
private readonly IMxAccessComObjectFactory? comFactory;
|
||||
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
|
||||
private readonly Func<IAlarmAckWriter>? ackWriterFactory;
|
||||
private readonly Action? ackPumpStep;
|
||||
private readonly MxAccessEventMapper mapper = new MxAccessEventMapper();
|
||||
private readonly object syncRoot = new object();
|
||||
private readonly IAlarmAckWriter deferredAckWriter;
|
||||
@@ -131,13 +132,21 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
/// unsubscribe/resubscribe, and is disposed with the handler. Tests
|
||||
/// inject a fake.
|
||||
/// </param>
|
||||
/// <param name="ackPumpStep">
|
||||
/// Pump step the default <see cref="AlarmAckWriter"/> drives while it waits
|
||||
/// for OnWriteComplete. Production passes the STA runtime's
|
||||
/// <c>StaRuntime.PumpPendingMessages</c>, which also refreshes the STA
|
||||
/// watchdog heartbeat; when <see langword="null"/> a bare
|
||||
/// <see cref="StaMessagePump"/> is used (no heartbeat refresh).
|
||||
/// </param>
|
||||
public AlarmCommandHandler(
|
||||
MxAccessEventQueue eventQueue,
|
||||
Func<IMxAccessAlarmConsumer> consumerFactory,
|
||||
Action? threadAffinityCheck,
|
||||
IMxAccessComObjectFactory? comFactory,
|
||||
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
|
||||
Func<IAlarmAckWriter>? ackWriterFactory = null)
|
||||
Func<IAlarmAckWriter>? ackWriterFactory = null,
|
||||
Action? ackPumpStep = null)
|
||||
{
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
|
||||
@@ -145,6 +154,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
this.comFactory = comFactory;
|
||||
this.standbyFactory = standbyFactory;
|
||||
this.ackWriterFactory = ackWriterFactory;
|
||||
this.ackPumpStep = ackPumpStep;
|
||||
this.deferredAckWriter = new DeferredAckWriter(this);
|
||||
}
|
||||
|
||||
@@ -399,12 +409,14 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
"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.
|
||||
// The pump step is PeekMessage/DispatchMessage on the calling thread —
|
||||
// the STA, since acks run there — which is what delivers the writer's
|
||||
// OnWriteComplete callback. The injected StaRuntime pump also marks STA
|
||||
// activity so a long completion wait does not read as a hung STA.
|
||||
Action pump = ackPumpStep ?? (() => new StaMessagePump().PumpPendingMessages());
|
||||
ackWriter = new AlarmAckWriter(
|
||||
comFactory,
|
||||
() => new StaMessagePump().PumpPendingMessages(),
|
||||
pump,
|
||||
MxAccessStaSession.ResolveWriteCompletionTimeout(),
|
||||
AlarmAckWriter.ResolveClientName());
|
||||
return ackWriter;
|
||||
|
||||
@@ -48,7 +48,10 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
private readonly IMxAccessEventSink eventSink;
|
||||
private readonly MxAccessEventQueue eventQueue;
|
||||
private readonly StaRuntime staRuntime;
|
||||
private readonly Func<MxAccessEventQueue, Action, IMxAccessComObjectFactory, IAlarmCommandHandler>? alarmCommandHandlerFactory;
|
||||
// Pump-aware form: the fourth argument is the STA runtime's pump step, so
|
||||
// handler work that pumps while it waits (the alarm ack writer's
|
||||
// OnWriteComplete wait) also refreshes the STA watchdog heartbeat.
|
||||
private readonly Func<MxAccessEventQueue, Action, IMxAccessComObjectFactory, Action, IAlarmCommandHandler>? alarmCommandHandlerFactory;
|
||||
private StaCommandDispatcher? commandDispatcher;
|
||||
private MxAccessSession? session;
|
||||
private IAlarmCommandHandler? alarmCommandHandler;
|
||||
@@ -96,6 +99,23 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with default dependencies and a
|
||||
/// pump-aware alarm-command handler factory. Its fourth argument is a pump step bound to this
|
||||
/// session's <see cref="StaRuntime"/> (<see cref="StaRuntime.PumpPendingMessages"/>), which
|
||||
/// refreshes the STA watchdog heartbeat on every pump.
|
||||
/// </summary>
|
||||
/// <param name="pumpAwareAlarmCommandHandlerFactory">Factory that constructs the alarm-command handler.</param>
|
||||
internal MxAccessStaSession(Func<MxAccessEventQueue, Action, IMxAccessComObjectFactory, Action, IAlarmCommandHandler> pumpAwareAlarmCommandHandlerFactory)
|
||||
{
|
||||
staRuntime = new StaRuntime();
|
||||
factory = new MxAccessComObjectFactory();
|
||||
eventQueue = new MxAccessEventQueue(MxAccessEventQueue.ResolveCapacity());
|
||||
eventSink = new MxAccessBaseEventSink(eventQueue);
|
||||
alarmCommandHandlerFactory = pumpAwareAlarmCommandHandlerFactory
|
||||
?? throw new ArgumentNullException(nameof(pumpAwareAlarmCommandHandlerFactory));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom STA runtime and factory.
|
||||
/// </summary>
|
||||
@@ -183,7 +203,9 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
this.alarmCommandHandlerFactory = alarmCommandHandlerFactory;
|
||||
this.alarmCommandHandlerFactory = alarmCommandHandlerFactory is null
|
||||
? null
|
||||
: (eq, affinity, comFactory, _) => alarmCommandHandlerFactory(eq, affinity, comFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -288,7 +310,8 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
alarmCommandHandler = alarmCommandHandlerFactory(
|
||||
eventQueue,
|
||||
EnsureOnAlarmConsumerThread,
|
||||
factory);
|
||||
factory,
|
||||
() => staRuntime.PumpPendingMessages());
|
||||
}
|
||||
commandDispatcher = new StaCommandDispatcher(
|
||||
staRuntime,
|
||||
|
||||
@@ -184,6 +184,57 @@ public sealed class MxAccessWriteCompletionCache
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets the completion entry of a handle pair whose MXAccess item was
|
||||
/// removed, so a long-lived writer that drops and re-adds items does not
|
||||
/// keep one entry per dropped handle for the life of the worker.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">MXAccess server handle.</param>
|
||||
/// <param name="itemHandle">MXAccess item handle that was removed.</param>
|
||||
public void Remove(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
entries.Remove(CreateItemKey(serverHandle, itemHandle));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forgets every completion entry recorded under a server handle that was unregistered.</summary>
|
||||
/// <param name="serverHandle">MXAccess server handle that was unregistered.</param>
|
||||
public void RemoveServer(int serverHandle)
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
List<long> stale = new();
|
||||
foreach (long key in entries.Keys)
|
||||
{
|
||||
if ((int)(key >> 32) == serverHandle)
|
||||
{
|
||||
stale.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (long key in stale)
|
||||
{
|
||||
entries.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the number of handle pairs with a recorded completion (test seam).</summary>
|
||||
internal int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
return entries.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long CreateItemKey(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
|
||||
@@ -202,14 +202,14 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
|
||||
// Use the IwwAlarmConsumer (v1) prefix-named methods for the
|
||||
// lifecycle. Empirically (live dev-rig 2026-05-01, reconfirmed
|
||||
// 2026-08-18) this is the only path that lets AlarmAckByName
|
||||
// return rc=0 afterwards. The v2 Initialize/Register/Subscribe
|
||||
// methods on the class succeed (return 0) but acks against that
|
||||
// 2026-08-18) the v1 path is the one that leaves the consumer in
|
||||
// a usable state: the v2 Initialize/Register/Subscribe methods on
|
||||
// the class succeed (return 0) but later calls against that
|
||||
// consumer state return -55. The v1 prefix path is what
|
||||
// WIN-911-style code uses against the same wnwrap library.
|
||||
// Note rc=0 means the call was
|
||||
// accepted, not that an acknowledgement was applied — see
|
||||
// docs/AlarmProbeFindings.md. Acks now go through AlarmAckWriter.
|
||||
// WIN-911-style code uses against the same wnwrap library. This
|
||||
// consumer only reads the alarm snapshot; acknowledgements go
|
||||
// through AlarmAckWriter, not through wnwrap (see
|
||||
// docs/AlarmProbeFindings.md).
|
||||
int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName);
|
||||
if (init != 0)
|
||||
{
|
||||
@@ -615,10 +615,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
/// place under its original GUID and the re-raise replaced it with a
|
||||
/// freshly minted one. That is the intended reading — the old
|
||||
/// instance ended and a new one began — so the diff emits a Clear
|
||||
/// plus a Raise, which is what happened. The acknowledge leg remains
|
||||
/// assumed rather than observed: on the dev rig every ack surface is
|
||||
/// inert (<c>AlarmAckByName</c> returns 0 and nothing moves), so no
|
||||
/// record can be driven into an acknowledged state — see
|
||||
/// plus a Raise, which is what happened. Every alarm-client ack surface
|
||||
/// is inert on the dev rig (it returns 0 and nothing moves), so
|
||||
/// acknowledgements go through the AckMsg writer; an AckMsg write
|
||||
/// moves the record to <c>ACK_ALM</c> (observed 2026-10-19) — see
|
||||
/// <c>docs/AlarmProbeFindings.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
||||
Reference in New Issue
Block a user