Compare commits

...

39 Commits

Author SHA1 Message Date
Joseph Doherty 488649ce99 Merge branch 'feat/ackmsg-as-operator' into main (PR #162)
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m22s
ci / java (push) Successful in 2m7s
ci / portable (push) Failing after 4m10s
AcknowledgeAlarm acks by writing the comment to <alarm>.AckMsg authenticated
as the operator, so the Historian records the real operator. Fixes #164,
fixes #165, fixes #166.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 08:52:19 -04:00
Joseph Doherty 02228b2717 Merge branch 'main' into feat/ackmsg-as-operator
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m7s
ci / java (pull_request) Successful in 2m18s
ci / nightly-windev (pull_request) Has been skipped
ci / portable (push) Failing after 4m47s
ci / windows-x86 (pull_request) Successful in 2m40s
ci / portable (pull_request) Failing after 4m43s
ci / windows-x86 (push) Successful in 5m38s
Picks up the System.Security.Cryptography.Pkcs 10.0.13 pin (NU1903).

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:28:45 -04:00
Joseph Doherty fb20997dc9 Merge branch 'fix/pkcs-cve-pin' into main
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m47s
ci / java (push) Successful in 2m6s
ci / portable (push) Successful in 8m57s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:24:10 -04:00
Joseph Doherty 463748e395 fix(worker): classify ack interop failures at the COM call; ignore late completions
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m24s
ci / java (push) Successful in 2m32s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m38s
ci / portable (push) Failing after 2m52s
ci / portable (pull_request) Failing after 2m53s
ci / windows-x86 (pull_request) Successful in 8m8s
Address review of the #164-#166 fixes:

- The HRESULT check could not tell interop from programming errors
  (a managed ArgumentException already defaults to 0x80070057). Wrap each
  MXAccess call (AuthenticateUser, AddItem, Advise, Write) in a narrow
  filter that converts an interop failure to a private marker exception,
  and catch only that marker, so exceptions from the writer's own code
  (argument checks, caching, the pump) propagate.
- A late OnWriteComplete for a dropped handle recreated the cache entry
  that DropItem removed. The writer now records completions only for item
  handles it holds on its live registration.
- The item-handle-reuse probe now asserts that handles within one
  registration strictly increase, so a rerun is a regression check.

Refs #164, #165, #166

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:18:02 -04:00
Joseph Doherty bd82036d5f docs(worker): record live item-handle-reuse probe for ack writer
ci / windows-x86 (push) Successful in 1m29s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m47s
ci / nightly-windev (pull_request) Has been skipped
ci / portable (push) Failing after 3m1s
ci / portable (pull_request) Failing after 1m46s
ci / windows-x86 (pull_request) Successful in 1m24s
ci / java (pull_request) Successful in 2m36s
Add the Skip-gated ItemHandleReuseProbeTests probe and its windev result
(2026-10-20): within one MXAccess registration, item handles increase
monotonically and are never reused across remove/re-add, so the ack
writer's Unconfirmed drop-and-re-add mitigation holds and no per-handle
generation counter is needed. Numbering restarts only on a new
registration, which teardown already isolates. Document the guarantee at
the Dirty re-add and in AlarmProbeFindings.md.

Fixes #166

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:13:31 -04:00
Joseph Doherty f13950e453 fix(worker): forget write-completion entries for dropped ack item handles
Add MxAccessWriteCompletionCache.Remove and RemoveServer and call them
from AlarmAckWriter when it drops an item (dirty re-add, LRU eviction,
failed AddItem/Advise/Write) and when it tears down its registration, so
the cache no longer keeps one entry per dropped handle for the life of
the worker.

Fixes #165

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:11:37 -04:00
Joseph Doherty 219611c821 fix(worker): narrow AlarmAckWriter ArgumentException interop filter
Resolve the operator user, password and comment before the interop try
blocks, and match ArgumentException as an MXAccess failure only when it
carries E_INVALIDARG and is not ArgumentNullException or
ArgumentOutOfRangeException. A programming error inside the try blocks
now propagates instead of being reported as an authentication or write
failure.

Fixes #164

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:11:37 -04:00
Joseph Doherty a09fa0a4d6 fix: pin System.Security.Cryptography.Pkcs 10.0.13 (NU1903)
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m23s
ci / java (push) Successful in 2m19s
ci / portable (push) Successful in 10m44s
Microsoft.Data.SqlClient 6.0.2 pulls System.Security.Cryptography.Pkcs 9.0.4,
which carries GHSA-846f-f764-76mx (high) and GHSA-2fmv-rv2c-59x7 (moderate).
With TreatWarningsAsErrors the NU1902/NU1903 audit warnings failed restore.
10.0.13 patches both.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:07:59 -04:00
Joseph Doherty 3e988ede88 docs: record gateway-level live verification of AckMsg-as-operator acks
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m21s
ci / nightly-windev (pull_request) Has been skipped
ci / java (push) Successful in 2m3s
ci / portable (push) Failing after 2m19s
ci / windows-x86 (pull_request) Successful in 1m19s
ci / java (pull_request) Successful in 2m2s
ci / portable (pull_request) Failing after 2m17s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 20:07:36 -04:00
Joseph Doherty e7f56622c1 fix: address final ackmsg-as-operator review findings
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m0s
ci / windows-x86 (push) Successful in 2m11s
ci / portable (push) Failing after 2m21s
- AlarmAckWriter: classify AuthenticateUser and AddItem/Advise/Write
  failures by HRESULT for any interop exception (ArgumentException for
  E_INVALIDARG, UnauthorizedAccessException, COMException); server-gone
  HRESULTs at AuthenticateUser tear down the registration and report
  WriteRejected.
- Bound the ack item cache with a 256-entry LRU (eviction best-effort
  UnAdvise+RemoveItem).
- After an Unconfirmed ack, drop and re-add the item before the next
  write so a late OnWriteComplete on the old handle is not credited.
- Pass StaRuntime.PumpPendingMessages to the ack writer through a
  pump-aware alarm handler factory so long completion waits refresh the
  STA watchdog heartbeat.
- Rust CLI: --operator is required for acknowledge-alarm.
- Docs: full ack status table, credential-oracle trade-off, upgrade note.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:58:24 -04:00
Joseph Doherty 1998ee3e20 docs: address Task 12 review (supersede stale ack steps, outcome rows)
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m10s
ci / windows-x86 (push) Successful in 2m11s
ci / portable (push) Failing after 2m18s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:45:43 -04:00
Joseph Doherty 027cc7393d fix(python-client): redact operator password from AcknowledgeAlarmReply errors
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:45:02 -04:00
Joseph Doherty 7b81cb7f72 feat(python-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:45:02 -04:00
Joseph Doherty 0143591858 docs(dotnet-client): keep XML docs with their CLI members
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:56 -04:00
Joseph Doherty e8ace39418 feat(dotnet-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:56 -04:00
Joseph Doherty 9907a97cb0 fix(java-client): redact operator password on async acknowledge-alarm failures
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:32 -04:00
Joseph Doherty 84e04b6552 feat(java-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:32 -04:00
Joseph Doherty 417a47bdb5 feat(rust-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:32 -04:00
Joseph Doherty cfe9f817a3 feat(go-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:32 -04:00
Joseph Doherty 9409488008 docs: alarm acknowledgement writes AckMsg as the authenticated operator
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:21 -04:00
Joseph Doherty 2858e42e80 feat(gateway): forward operator credentials on alarm acknowledgement
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:40:23 -04:00
Joseph Doherty d6db0ff40d feat(worker): route alarm acks through the operator-authenticated AckMsg writer
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 2m5s
ci / windows-x86 (push) Successful in 2m10s
ci / portable (push) Failing after 2m17s
AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:35:55 -04:00
Joseph Doherty 0d4b2aea2d refactor(worker): alarm consumers resolve the AckMsg item instead of acking
IMxAccessAlarmConsumer.ResolveAckTarget/ResolveAckTargetByName replace AcknowledgeByGuid/AcknowledgeByName. wnwrap maps Galaxy-provider alarms to <tag>.AckMsg and drops its inert parallel ack consumer; subtag resolves to the target's AckCommentSubtag and no longer advises or writes it; ISubtagAlarmSource.Write is removed. Does not build alone: AlarmDispatcher moves to the new API in the next commit.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:35:49 -04:00
Joseph Doherty 8199553734 fix(worker): handle AckMsg add/advise/write COM failures and server death
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m55s
ci / java (push) Failing after 2m2s
ci / portable (push) Failing after 2m17s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:25:43 -04:00
Joseph Doherty 7900df3dce fix(worker): disambiguate MxStatusCategory in AlarmAckWriter
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 2m25s
ci / portable (push) Failing after 2m26s
ci / windows-x86 (push) Successful in 2m27s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:22:27 -04:00
Joseph Doherty 76db822df3 feat(worker): acknowledge alarms via authenticated AckMsg write
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 56s
ci / java (push) Failing after 2m17s
ci / portable (push) Failing after 2m23s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:21:55 -04:00
Joseph Doherty 64dc1215fd feat(worker): attribute alarm ack transitions from wnwrap ACK fields
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 2m7s
ci / java (push) Failing after 2m17s
ci / portable (push) Failing after 2m23s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:18:24 -04:00
Joseph Doherty a94b502ce6 feat(contracts): carry operator credentials on alarm acknowledgement
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:16:39 -04:00
Joseph Doherty bf4b9c6c09 docs(plan): AckMsg-as-operator alarm acknowledgement plan
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:15:20 -04:00
Joseph Doherty 91bbdbf154 Merge branch 'docs/managed-alarm-ack-live-result' into main
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m14s
ci / windows-x86 (push) Successful in 2m23s
ci / portable (push) Failing after 2m32s
2026-10-18 15:15:58 -04:00
Joseph Doherty 8c095b9a56 Record the managed GUID alarm-ack live result
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m25s
ci / java (push) Successful in 2m18s
ci / portable (push) Failing after 2m39s
The 2026-10-18 live test on the dev rig showed the opt-in managed ack path
cannot work as built: AlarmClient.RegisterConsumer returns -65 alongside the
worker's wnwrap reader (the worker then silently falls back to wnwrap), the
managed client does not recognise the wnwrap GUID even standalone, and the
gateway's GUID arm sends empty identity fields. Replace the docs' claim that
the path works with the measured results and tell operators to leave
MXGATEWAY_USE_MANAGED_ALARM_ACK unset.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 12:42:07 -04:00
dohertj2 6fc423f608 Merge pull request 'Retain fake worker fault diagnostics in test fixtures' (#155) from fix/fake-worker-fault-diagnostics into main
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m17s
ci / java (push) Successful in 2m3s
ci / portable (push) Successful in 8m23s
2026-10-13 10:46:14 -04:00
Joseph Doherty 83aa76c33d Retain fake worker fault diagnostics in test fixtures
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m20s
ci / java (push) Successful in 2m19s
ci / windows-x86 (pull_request) Successful in 1m20s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m23s
ci / portable (push) Successful in 9m10s
ci / portable (pull_request) Successful in 9m2s
2026-10-13 10:35:28 -04:00
dohertj2 d779daf6af Merge pull request 'Make framed worker and readiness test gates deterministic' (#153) from fix/framed-worker-test-gates into main
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m22s
ci / java (push) Successful in 2m2s
ci / portable (push) Successful in 8m26s
2026-10-13 09:59:45 -04:00
Joseph Doherty 42c120aa83 Preserve framed reads across fake worker emission gates
ci / windows-x86 (push) Successful in 1m20s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m17s
ci / windows-x86 (pull_request) Successful in 1m25s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m27s
ci / portable (push) Successful in 9m43s
ci / portable (pull_request) Successful in 9m47s
2026-10-13 09:42:09 -04:00
dohertj2 19920b13da Merge pull request 'Allow redundant must_use only in generated Rust service traits' (#152) from fix/generated-rust-must-use into main
ci / windows-x86 (push) Successful in 1m25s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m15s
ci / portable (push) Successful in 9m6s
2026-10-13 09:37:53 -04:00
104 changed files with 8399 additions and 2050 deletions
+1 -1
View File
@@ -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`. - **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. - **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. - **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. - **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*. - **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. - **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.
+10 -3
View File
@@ -143,8 +143,10 @@ the same `MxEventStreamItem` surface.
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
the active alarms the gateway's central monitor currently holds), the active alarms the gateway's central monitor currently holds),
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages `StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm keyed by the same monitor), and `AcknowledgeAlarmAsync` (acknowledges as
reference, optional comment, ack target). All three accept a cancellation 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 token and pass through the `MxGateway:Alarms` configuration on the
server — when alarms are disabled, the gateway returns an empty list / empty server — when alarms are disabled, the gateway returns an empty list / empty
stream rather than failing. 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 -- 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-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 -- 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 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, `smoke` opens a session, registers a client, adds one item, advises it,
optionally writes a value when `--type` and `--value` are supplied, reads a 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 bounded event stream, and closes the session in a `finally` block. CLI error
@@ -185,6 +185,7 @@ public static class MxGatewayClientCli
exception.Message, exception.Message,
apiKey, apiKey,
TryResolveVerifyUserPassword(arguments), TryResolveVerifyUserPassword(arguments),
ResolveOperatorPassword(arguments),
arguments.GetOptional("value")); arguments.GetOptional("value"));
if (forceJsonErrors || arguments.HasFlag("json")) if (forceJsonErrors || arguments.HasFlag("json"))
@@ -352,6 +353,12 @@ public static class MxGatewayClientCli
/// </summary> /// </summary>
private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD"; 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> /// <summary>
/// Legacy environment variable, still honoured as a deprecated fallback /// Legacy environment variable, still honoured as a deprecated fallback
/// for one release so existing scripts keep working. /// for one release so existing scripts keep working.
@@ -378,6 +385,30 @@ public static class MxGatewayClientCli
: environmentName; : 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> /// <summary>
/// Resolves the effective MXAccess verify-user credential in this /// Resolves the effective MXAccess verify-user credential in this
/// order: <c>--password</c>, the deprecated <c>--verify-user-password</c> /// order: <c>--password</c>, the deprecated <c>--verify-user-password</c>
@@ -1535,6 +1566,8 @@ public static class MxGatewayClientCli
AlarmFullReference = arguments.GetRequired("reference"), AlarmFullReference = arguments.GetRequired("reference"),
Comment = arguments.GetOptional("comment") ?? string.Empty, Comment = arguments.GetOptional("comment") ?? string.Empty,
OperatorUser = arguments.GetOptional("operator") ?? string.Empty, OperatorUser = arguments.GetOptional("operator") ?? string.Empty,
OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty,
OperatorPassword = ResolveOperatorPassword(arguments) ?? string.Empty,
}; };
return WriteReplyAsync( 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 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-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 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 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 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]"); writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
@@ -36,6 +36,8 @@ public sealed class MxGatewayClientAlarmsTests
AlarmFullReference = "Tank01.Level.HiHi", AlarmFullReference = "Tank01.Level.HiHi",
Comment = "investigating", Comment = "investigating",
OperatorUser = "alice", OperatorUser = "alice",
OperatorPassword = "pw-secret",
OperatorDomain = "CORP",
}); });
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); 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("Tank01.Level.HiHi", call.Request.AlarmFullReference);
Assert.Equal("investigating", call.Request.Comment); Assert.Equal("investigating", call.Request.Comment);
Assert.Equal("alice", call.Request.OperatorUser); 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")); 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> /// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
@@ -763,6 +763,7 @@ public sealed class MxGatewayClientCliTests
[Fact] [Fact]
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply() public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
{ {
Environment.SetEnvironmentVariable("MXGW_TEST_OPPW", "op-pw-secret");
using var output = new StringWriter(); using var output = new StringWriter();
using var error = new StringWriter(); using var error = new StringWriter();
FakeCliClient fakeClient = new(); FakeCliClient fakeClient = new();
@@ -786,6 +787,10 @@ public sealed class MxGatewayClientCliTests
"ack from cli", "ack from cli",
"--operator", "--operator",
"operator1", "operator1",
"--operator-domain",
"CORP",
"--operator-password-env",
"MXGW_TEST_OPPW",
"--json", "--json",
], ],
output, output,
@@ -797,6 +802,9 @@ public sealed class MxGatewayClientCliTests
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference); Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
Assert.Equal("ack from cli", request.Comment); Assert.Equal("ack from cli", request.Comment);
Assert.Equal("operator1", request.OperatorUser); 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.Contains("ack-fixture", output.ToString());
Assert.Equal(string.Empty, error.ToString()); Assert.Equal(string.Empty, error.ToString());
} }
@@ -183,24 +183,32 @@ public sealed class MxGatewayClient : IAsyncDisposable
} }
/// <summary> /// <summary>
/// Acknowledges an active MXAccess alarm condition through the gateway. The /// Acknowledges an alarm as the operator. The gateway authenticates
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c> /// <see cref="AcknowledgeAlarmRequest.OperatorUser"/> with
/// scope and forwards the acknowledge to the worker's MXAccess session; /// <see cref="AcknowledgeAlarmRequest.OperatorPassword"/> and writes the
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply. /// comment to the alarm's AckMsg attribute, so the alarm history records
/// the operator. The password is redacted from any surfaced error.
/// </summary> /// </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> /// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns> /// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync( public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request, AcknowledgeAlarmRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed(); ThrowIfDisposed();
return ExecuteSafeUnaryAsync( try
{
return await ExecuteSafeUnaryAsync(
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)), token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
cancellationToken); cancellationToken).ConfigureAwait(false);
}
catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0)
{
throw MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword);
}
} }
/// <summary> /// <summary>
+6 -2
View File
@@ -140,7 +140,11 @@ sentinel typed and observable; it never synthesizes or swallows it.
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
snapshots, `Client.StreamAlarms` for the server-streaming feed, and 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 call returns a `StreamAlarmsClient`; cancel its context to terminate the
stream. All three pass straight through to the gateway's central alarm stream. All three pass straight through to the gateway's central alarm
monitor. 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`). | | `bench-read-bulk` | Throughput benchmark (`-duration-seconds`, `-warmup-seconds`, `-bulk-size`). |
| `stream-events` | Stream item-value events for a session (`-session-id`, `-limit`). | | `stream-events` | Stream item-value events for a session (`-session-id`, `-limit`). |
| `stream-alarms` | Stream the alarm feed (`-filter-prefix`, `-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. | | `smoke` | End-to-end smoke workflow against one item. |
| `galaxy-test-connection` | Probe the Galaxy Repository RPC connection. | | `galaxy-test-connection` | Probe the Galaxy Repository RPC connection. |
| `galaxy-last-deploy` | Print the most recent deploy event. | | `galaxy-last-deploy` | Print the most recent deploy event. |
+23 -1
View File
@@ -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 { func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError) flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError)
flags.SetOutput(stderr) 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") jsonOutput := flags.Bool("json", false, "write JSON output")
reference := flags.String("reference", "", "full alarm reference to acknowledge") reference := flags.String("reference", "", "full alarm reference to acknowledge")
comment := flags.String("comment", "", "operator acknowledge comment") 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 { if err := flags.Parse(args); err != nil {
return err return err
@@ -1134,6 +1143,17 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
if *reference == "" { if *reference == "" {
return errors.New("reference is required") 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) client, options, err := dialForCommand(ctx, common)
if err != nil { if err != nil {
@@ -1145,6 +1165,8 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
AlarmFullReference: *reference, AlarmFullReference: *reference,
Comment: *comment, Comment: *comment,
OperatorUser: *operator, OperatorUser: *operator,
OperatorDomain: *operatorDomain,
OperatorPassword: resolvedOperatorPassword,
}) })
if err != nil { if err != nil {
return err return err
@@ -3583,10 +3583,13 @@ func (x *AlarmFailoverConfig) GetFailbackStableProbes() int32 {
return 0 return 0
} }
// Acknowledge a single alarm by its GUID. Operator identity fields are // Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
// recorded atomically with the ack transition in the alarm-history log. // alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
// The reply's hresult / native_status surfaces AVEVA's // by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
// AlarmAckByGUID return code. // 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 { type AcknowledgeAlarmCommand struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73"). // 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"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -3672,6 +3679,13 @@ func (x *AcknowledgeAlarmCommand) GetOperatorFullName() string {
return "" return ""
} }
func (x *AcknowledgeAlarmCommand) GetOperatorPassword() string {
if x != nil {
return x.OperatorPassword
}
return ""
}
// Snapshot the currently-active alarm set. Optional filter prefix scopes // Snapshot the currently-active alarm set. Optional filter prefix scopes
// the snapshot to alarms whose alarm_full_reference starts with the // the snapshot to alarms whose alarm_full_reference starts with the
// supplied string (matches QueryActiveAlarmsRequest.alarm_filter_prefix). // 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 // Acknowledge a single alarm by its (name, provider, group) tuple. Used
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot // when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
// be resolved to a GUID directly. The worker invokes // be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
// history path as AlarmAckByGUID.
type AcknowledgeAlarmByNameCommand struct { type AcknowledgeAlarmByNameCommand struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself // 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"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -3829,6 +3845,13 @@ func (x *AcknowledgeAlarmByNameCommand) GetOperatorFullName() string {
return "" return ""
} }
func (x *AcknowledgeAlarmByNameCommand) GetOperatorPassword() string {
if x != nil {
return x.OperatorPassword
}
return ""
}
type UnsubscribeBulkCommand struct { type UnsubscribeBulkCommand struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"` 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 // Reply payload for AcknowledgeAlarmCommand AND
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this // AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
// payload case (`MxCommandReply.acknowledge_alarm`); there is no // payload case (`MxCommandReply.acknowledge_alarm`); there is no
// dedicated by-name reply case. Surfaces AVEVA's native ack return // dedicated by-name reply case. Surfaces the ack outcome: 0 when the
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the // AckMsg write was applied or sent but unconfirmed; otherwise the
// by-name arm); 0 means success. The MxCommandReply's hresult field // AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
// carries the same value and is preferred for protocol consumers — // 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 // can echo native_status into AcknowledgeAlarmReply.hresult without
// unpacking the outer envelope. // unpacking the outer envelope.
type AcknowledgeAlarmReplyPayload struct { type AcknowledgeAlarmReplyPayload struct {
@@ -7218,6 +7241,15 @@ type AcknowledgeAlarmRequest struct {
// Operator principal performing the acknowledgement. The lmxopcua side // Operator principal performing the acknowledgement. The lmxopcua side
// resolves this from the OPC UA session prior to invoking the RPC. // 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -7280,14 +7312,27 @@ func (x *AcknowledgeAlarmRequest) GetOperatorUser() string {
return "" 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 { type AcknowledgeAlarmReply struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` 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"` 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 // Native ack return code echoed from the worker. The worker carries the
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status, // ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's // 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
// ack-outcome field for the public RPC. Absent only when the worker reply // ack-outcome field for the public RPC. Absent only when the worker reply
// omitted the value entirely (a protocol violation). // omitted the value entirely (a protocol violation).
Hresult *int32 `protobuf:"varint,4,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"` 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" + "\x13AlarmFailoverConfig\x12B\n" +
"\x1dconsecutive_failure_threshold\x18\x01 \x01(\x05R\x1bconsecutiveFailureThreshold\x12E\n" + "\x1dconsecutive_failure_threshold\x18\x01 \x01(\x05R\x1bconsecutiveFailureThreshold\x12E\n" +
"\x1ffailback_probe_interval_seconds\x18\x02 \x01(\x05R\x1cfailbackProbeIntervalSeconds\x124\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" + "\x17AcknowledgeAlarmCommand\x12\x1d\n" +
"\n" + "\n" +
"alarm_guid\x18\x01 \x01(\tR\talarmGuid\x12\x18\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_user\x18\x03 \x01(\tR\foperatorUser\x12#\n" +
"\roperator_node\x18\x04 \x01(\tR\foperatorNode\x12'\n" + "\roperator_node\x18\x04 \x01(\tR\foperatorNode\x12'\n" +
"\x0foperator_domain\x18\x05 \x01(\tR\x0eoperatorDomain\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" + "\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" + "\x1dAcknowledgeAlarmByNameCommand\x12\x1d\n" +
"\n" + "\n" +
"alarm_name\x18\x01 \x01(\tR\talarmName\x12#\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_user\x18\x05 \x01(\tR\foperatorUser\x12#\n" +
"\roperator_node\x18\x06 \x01(\tR\foperatorNode\x12'\n" + "\roperator_node\x18\x06 \x01(\tR\foperatorNode\x12'\n" +
"\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomain\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" + "\x16UnsubscribeBulkCommand\x12#\n" +
"\rserver_handle\x18\x01 \x01(\x05R\fserverHandle\x12!\n" + "\rserver_handle\x18\x01 \x01(\x05R\fserverHandle\x12!\n" +
"\fitem_handles\x18\x02 \x03(\x05R\vitemHandles\"v\n" + "\fitem_handles\x18\x02 \x03(\x05R\vitemHandles\"v\n" +
@@ -9262,12 +9309,14 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
"limitValue\x12\x1a\n" + "limitValue\x12\x1a\n" +
"\bdegraded\x18\x0e \x01(\bR\bdegraded\x12O\n" + "\bdegraded\x18\x0e \x01(\bR\bdegraded\x12O\n" +
"\x0fsource_provider\x18\x0f \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x0esourceProvider\x126\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" + "\x17AcknowledgeAlarmRequest\x122\n" +
"\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" + "\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" +
"\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" + "\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" +
"\acomment\x18\x04 \x01(\tR\acomment\x12#\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" + "session_id\"\xb4\x02\n" +
"\x15AcknowledgeAlarmReply\x12%\n" + "\x15AcknowledgeAlarmReply\x12%\n" +
"\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x12L\n" + "\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x12L\n" +
+7 -2
View File
@@ -10,6 +10,11 @@ import (
// invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess // invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess
// session; the resulting native MxStatus is returned in the reply. // 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 // Acks are idempotent — re-acking an already-acked condition is a no-op at
// the MxAccess layer. // the MxAccess layer.
func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) { 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) reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
if err != nil { 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 { if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
return reply, err return reply, redactSecrets(err, req.GetOperatorPassword())
} }
return reply, nil return reply, nil
+29
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"io" "io"
"net" "net"
"strings"
"testing" "testing"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
@@ -38,6 +39,8 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
AlarmFullReference: "Tank01.Level.HiHi", AlarmFullReference: "Tank01.Level.HiHi",
Comment: "investigating", Comment: "investigating",
OperatorUser: "alice", OperatorUser: "alice",
OperatorPassword: "pw-secret",
OperatorDomain: "CORP",
}) })
if err != nil { if err != nil {
t.Fatalf("AcknowledgeAlarm() error = %v", err) t.Fatalf("AcknowledgeAlarm() error = %v", err)
@@ -51,11 +54,37 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
if got := fake.acknowledgeRequest.GetComment(); got != "investigating" { if got := fake.acknowledgeRequest.GetComment(); got != "investigating" {
t.Fatalf("captured comment = %q", got) 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" { if got := fake.acknowledgeAuth; got != "Bearer test-api-key" {
t.Fatalf("authorization metadata = %q", got) 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) { func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
fake := &fakeGatewayWithAlarms{} fake := &fakeGatewayWithAlarms{}
client, cleanup := newBufconnClientWithAlarms(t, fake) client, cleanup := newBufconnClientWithAlarms(t, fake)
+6 -3
View File
@@ -114,8 +114,11 @@ try (MxEventStream events = session.streamEventsAfter(lastSeenSequence)) {
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot), For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator `streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
yields alarm-feed messages from the gateway's central monitor), and yields alarm-feed messages from the gateway's central monitor), and
`acknowledgeAlarm` (ack by full alarm reference with an optional comment and `acknowledgeAlarm` (ack by full alarm reference with an optional comment).
ack target). Close the subscription to cancel the underlying gRPC stream. 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 `ActiveAlarmSnapshot.getFromTruncatedSnapshot()` reports that the record came
from a provider fetch which hit the per-fetch cap: the snapshot set may omit 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="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-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="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" gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
``` ```
File diff suppressed because it is too large Load Diff
@@ -1822,6 +1822,31 @@ public final class MxGatewayCli implements Callable<Integer> {
@Option(names = "--operator", description = "Operator user performing the acknowledge.") @Option(names = "--operator", description = "Operator user performing the acknowledge.")
String operator = ""; 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) { AcknowledgeAlarmCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory); super(clientFactory);
} }
@@ -1833,6 +1858,8 @@ public final class MxGatewayCli implements Callable<Integer> {
.setAlarmFullReference(reference) .setAlarmFullReference(reference)
.setComment(comment) .setComment(comment)
.setOperatorUser(operator) .setOperatorUser(operator)
.setOperatorDomain(operatorDomain == null ? "" : operatorDomain)
.setOperatorPassword(resolveOperatorPassword())
.build()); .build());
writeOutput( writeOutput(
"acknowledge-alarm", "acknowledge-alarm",
@@ -714,9 +714,16 @@ final class MxGatewayCliTests {
"checked", "checked",
"--operator", "--operator",
"operator1", "operator1",
"--operator-domain",
"CORP",
"--operator-password",
"pw-secret",
"--json"); "--json");
assertEquals(0, run.exitCode()); 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("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment()); assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment());
assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser()); assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
@@ -770,6 +777,10 @@ final class MxGatewayCliTests {
"--plaintext", "--plaintext",
"--reference", "--reference",
"\\Galaxy\\Area001.Pump001.PumpFault", "\\Galaxy\\Area001.Pump001.PumpFault",
"--operator",
"operator1",
"--operator-password-env",
"MXGATEWAY_OPERATOR_PASSWORD",
"--json" "--json"
}; };
assertReadmeExampleParses(args); assertReadmeExampleParses(args);
@@ -273,6 +273,11 @@ public final class MxGatewayClient implements AutoCloseable {
* worker's MXAccess session; the resulting native MxStatus is returned * worker's MXAccess session; the resulting native MxStatus is returned
* in the reply. Acks are idempotent at the MxAccess layer. * 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} * @param request the {@code AcknowledgeAlarmRequest}
* @return the raw acknowledge reply * @return the raw acknowledge reply
* @throws MxGatewayException on transport or protocol failure * @throws MxGatewayException on transport or protocol failure
@@ -283,10 +288,14 @@ public final class MxGatewayClient implements AutoCloseable {
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null); MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
return reply; return reply;
} catch (RuntimeException error) { } catch (RuntimeException error) {
if (error instanceof MxGatewayException) { MxGatewayException mapped = error instanceof MxGatewayException gatewayError
throw error; ? 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) { public CompletableFuture<AcknowledgeAlarmReply> acknowledgeAlarmAsync(AcknowledgeAlarmRequest request) {
CompletableFuture<AcknowledgeAlarmReply> future = toCompletable(rawFutureStub().acknowledgeAlarm(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); MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
return reply; 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);
});
} }
/** /**
@@ -28,6 +28,43 @@ final class MxGatewayErrors {
return new MxGatewayException("gateway " + operation + " failed: " + error.getMessage(), error); 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) { static void ensureProtocolSuccess(String operation, ProtocolStatus status, MxCommandReply reply) {
if (status == null || status.getCode() == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) { if (status == null || status.getCode() == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) {
return; return;
@@ -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 @Test
void authenticateUserMissingPayloadThrowsMalformedReply() throws Exception { void authenticateUserMissingPayloadThrowsMalformedReply() throws Exception {
MxCommandReply reply = loadReply("authenticate-user.missing-payload.reply.json"); MxCommandReply reply = loadReply("authenticate-user.missing-payload.reply.json");
+10 -4
View File
@@ -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 For alarms, the client exposes `GatewayClient.query_active_alarms` (one-shot
snapshot), `GatewayClient.stream_alarms` (async generator yielding alarm-feed snapshot), `GatewayClient.stream_alarms` (async generator yielding alarm-feed
messages from the gateway's central monitor), and messages from the gateway's central monitor), and
`GatewayClient.acknowledge_alarm` (ack by alarm reference, optional comment `GatewayClient.acknowledge_alarm` (ack by alarm reference with an optional
and ack target). Cancel the surrounding task or `aclose()` the iterator to comment). The ack is made as the operator: set `operator_user` (required) and
terminate the stream. `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 `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 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 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-events --session-id <id> --max-events 1 --json
mxgw-py stream-alarms --max-messages 4 --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 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 import grpc
from .auth import merge_metadata 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 as pb
from .generated import mxaccess_gateway_pb2_grpc as pb_grpc from .generated import mxaccess_gateway_pb2_grpc as pb_grpc
from .options import ClientOptions, create_channel, resolve_channel_security from .options import ClientOptions, create_channel, resolve_channel_security
@@ -148,17 +148,23 @@ class GatewayClient:
self, self,
request: pb.AcknowledgeAlarmRequest, request: pb.AcknowledgeAlarmRequest,
) -> pb.AcknowledgeAlarmReply: ) -> 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 The gateway authenticates ``operator_user`` with ``operator_password`` and
``invoke:alarm-ack`` scope and forwards the acknowledge to the worker's writes the comment to the alarm's ``AckMsg``, so the alarm history records
MXAccess session; the resulting native ``MxStatus`` is returned in the the operator. The password is redacted from any raised error.
reply. Acks are idempotent — re-acking an already-acked condition is a
no-op at the MxAccess layer.
""" """
try:
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request) reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply) ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
return 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( def query_active_alarms(
self, self,
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncIterator, Sequence from collections.abc import AsyncIterator, Sequence
from typing import Any
from .auth import redact_secret from .auth import redact_secret
from .errors import MalformedReplyError, MxGatewayError, ensure_mxaccess_success from .errors import MalformedReplyError, MxGatewayError, ensure_mxaccess_success
@@ -943,19 +944,23 @@ def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
error.raw_reply = _redact_reply(error.raw_reply, scrubbed) 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. """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. 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 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. 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 = type(reply)()
clone.CopyFrom(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) 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) clone.diagnostic_message = redact_secret(clone.diagnostic_message, secrets)
if "statuses" in fields:
for status in clone.statuses: for status in clone.statuses:
if status.diagnostic_text: if status.diagnostic_text:
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets) status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
@@ -36,6 +36,7 @@ MAX_AGGREGATE_EVENTS = 10_000
#: CLI (CLI-45) so one exported variable drives the same operator workflow in all #: CLI (CLI-45) so one exported variable drives the same operator workflow in all
#: five languages. #: five languages.
DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD" DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD"
DEFAULT_OPERATOR_PASSWORD_ENV = "MXGATEWAY_OPERATOR_PASSWORD"
_BATCH_EOR = "__MXGW_BATCH_EOR__" _BATCH_EOR = "__MXGW_BATCH_EOR__"
@@ -528,16 +529,30 @@ def stream_alarms(**kwargs: Any) -> None:
@gateway_options @gateway_options
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.") @click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
@click.option("--comment", default="", help="Acknowledgement comment.") @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("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.") @click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def acknowledge_alarm(**kwargs: Any) -> None: 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( _run(
_acknowledge_alarm(**kwargs), _acknowledge_alarm(**kwargs),
output_json=kwargs["output_json"], 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} 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: def _resolve_password(kwargs: dict[str, Any]) -> str:
"""Resolve the authenticate-user password from --password or --password-env. """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"], alarm_full_reference=kwargs["reference"],
comment=kwargs["comment"], comment=kwargs["comment"],
operator_user=kwargs["operator"], operator_user=kwargs["operator"],
operator_domain=kwargs["operator_domain"],
operator_password=kwargs["operator_password"],
), ),
) )
return _message_dict(reply) return _message_dict(reply)
+61
View File
@@ -34,6 +34,8 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
alarm_full_reference="Tank01.Level.HiHi", alarm_full_reference="Tank01.Level.HiHi",
comment="investigating", comment="investigating",
operator_user="alice", 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.alarm_full_reference == "Tank01.Level.HiHi"
assert captured_request.comment == "investigating" assert captured_request.comment == "investigating"
assert captured_request.operator_user == "alice" 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"),) 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 @pytest.mark.asyncio
async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None: async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None:
stub = FakeGatewayStub() 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: def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
monkeypatch.setenv("MXGW_TEST_OPPW", "pw-secret")
stub = _BulkFakeStub() stub = _BulkFakeStub()
stub.acknowledge_alarm = _BulkFakeUnary( stub.acknowledge_alarm = _BulkFakeUnary(
[ [
@@ -798,12 +799,19 @@ def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
"investigating", "investigating",
"--operator", "--operator",
"alice", "alice",
"--operator-domain",
"CORP",
"--operator-password-env",
"MXGW_TEST_OPPW",
"--json", "--json",
], ],
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert "pw-secret" not in result.output
captured_request = stub.acknowledge_alarm.requests[0] 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.alarm_full_reference == "Tank01.Level.HiHi"
assert captured_request.comment == "investigating" assert captured_request.comment == "investigating"
assert captured_request.operator_user == "alice" assert captured_request.operator_user == "alice"
+5 -1
View File
@@ -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 -- 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-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 -- 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 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` `stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms`
returns an `AlarmFeedStream` async stream of alarm-feed messages and returns an `AlarmFeedStream` async stream of alarm-feed messages and
shares the gateway's central alarm monitor with every other client. 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 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 a provider fetch which hit the per-fetch cap: the snapshot set may omit active
+3 -1
View File
@@ -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. attach. Dropping the stream cancels the gRPC call cooperatively.
`acknowledge_alarm` is idempotent at the MxAccess layer; the returned `acknowledge_alarm` is idempotent at the MxAccess layer; the returned
`AcknowledgeAlarmReply` carries the native MxStatus from the worker. `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 ## Galaxy Repository
@@ -346,7 +348,7 @@ mxgw write-secured-bulk ...
mxgw write-secured2-bulk ... mxgw write-secured2-bulk ...
mxgw stream-events --session-id <id> --json mxgw stream-events --session-id <id> --json
mxgw stream-alarms [--filter-prefix <prefix>] [--max-events <n>] 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 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 smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
mxgw batch mxgw batch
+33 -1
View File
@@ -374,8 +374,19 @@ enum Command {
reference: String, reference: String,
#[arg(long, default_value = "")] #[arg(long, default_value = "")]
comment: String, 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, 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)] #[arg(long)]
json: bool, json: bool,
}, },
@@ -1081,8 +1092,16 @@ async fn dispatch(command: Command) -> Result<(), Error> {
reference, reference,
comment, comment,
operator, operator,
operator_domain,
operator_password,
operator_password_env,
json, 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 client = connect(connection).await?;
let reply = client let reply = client
.acknowledge_alarm(AcknowledgeAlarmRequest { .acknowledge_alarm(AcknowledgeAlarmRequest {
@@ -1090,6 +1109,8 @@ async fn dispatch(command: Command) -> Result<(), Error> {
alarm_full_reference: reference, alarm_full_reference: reference,
comment, comment,
operator_user: operator, operator_user: operator,
operator_password,
operator_domain,
}) })
.await?; .await?;
print_acknowledge_alarm_reply(&reply, json); print_acknowledge_alarm_reply(&reply, json);
@@ -2771,6 +2792,17 @@ mod tests {
assert!(parsed.is_ok(), "parse failed: {parsed:?}"); 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] #[test]
fn acknowledge_alarm_requires_reference() { fn acknowledge_alarm_requires_reference() {
let parsed = Cli::try_parse_from(["mxgw", "acknowledge-alarm"]); let parsed = Cli::try_parse_from(["mxgw", "acknowledge-alarm"]);
+30 -13
View File
@@ -391,10 +391,13 @@ message AlarmFailoverConfig {
int32 failback_stable_probes = 3; // clean probes before switching back (>=1) int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
} }
// Acknowledge a single alarm by its GUID. Operator identity fields are // Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
// recorded atomically with the ack transition in the alarm-history log. // alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
// The reply's hresult / native_status surfaces AVEVA's // by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
// AlarmAckByGUID return code. // 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.
message AcknowledgeAlarmCommand { message AcknowledgeAlarmCommand {
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73"). // Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
string alarm_guid = 1; string alarm_guid = 1;
@@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand {
string operator_node = 4; string operator_node = 4;
string operator_domain = 5; string operator_domain = 5;
string operator_full_name = 6; string operator_full_name = 6;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 7;
} }
// Snapshot the currently-active alarm set. Optional filter prefix scopes // Snapshot the currently-active alarm set. Optional filter prefix scopes
@@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand {
// Acknowledge a single alarm by its (name, provider, group) tuple. Used // Acknowledge a single alarm by its (name, provider, group) tuple. Used
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot // when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
// be resolved to a GUID directly. The worker invokes // be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
// history path as AlarmAckByGUID.
message AcknowledgeAlarmByNameCommand { message AcknowledgeAlarmByNameCommand {
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself // Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
// may contain dots; the gateway-side parser splits on the first dot // may contain dots; the gateway-side parser splits on the first dot
@@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand {
string operator_node = 6; string operator_node = 6;
string operator_domain = 7; string operator_domain = 7;
string operator_full_name = 8; string operator_full_name = 8;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 9;
} }
message UnsubscribeBulkCommand { message UnsubscribeBulkCommand {
@@ -725,11 +734,11 @@ message DrainEventsReply {
// Reply payload for AcknowledgeAlarmCommand AND // Reply payload for AcknowledgeAlarmCommand AND
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this // AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
// payload case (`MxCommandReply.acknowledge_alarm`); there is no // payload case (`MxCommandReply.acknowledge_alarm`); there is no
// dedicated by-name reply case. Surfaces AVEVA's native ack return // dedicated by-name reply case. Surfaces the ack outcome: 0 when the
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the // AckMsg write was applied or sent but unconfirmed; otherwise the
// by-name arm); 0 means success. The MxCommandReply's hresult field // AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
// carries the same value and is preferred for protocol consumers — // 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 // can echo native_status into AcknowledgeAlarmReply.hresult without
// unpacking the outer envelope. // unpacking the outer envelope.
message AcknowledgeAlarmReplyPayload { message AcknowledgeAlarmReplyPayload {
@@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest {
// Operator principal performing the acknowledgement. The lmxopcua side // Operator principal performing the acknowledgement. The lmxopcua side
// resolves this from the OPC UA session prior to invoking the RPC. // resolves this from the OPC UA session prior to invoking the RPC.
string operator_user = 5; string operator_user = 5;
// 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.
string operator_password = 6;
// Optional domain for OS-authenticated galaxies. When set the gateway
// authenticates as "<operator_domain>\<operator_user>".
string operator_domain = 7;
} }
message AcknowledgeAlarmReply { message AcknowledgeAlarmReply {
@@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply {
ProtocolStatus protocol_status = 3; ProtocolStatus protocol_status = 3;
// Native ack return code echoed from the worker. The worker carries the // Native ack return code echoed from the worker. The worker carries the
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status, // ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's // 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
// ack-outcome field for the public RPC. Absent only when the worker reply // ack-outcome field for the public RPC. Absent only when the worker reply
// omitted the value entirely (a protocol violation). // omitted the value entirely (a protocol violation).
optional int32 hresult = 4; optional int32 hresult = 4;
+9
View File
@@ -327,6 +327,9 @@ impl GatewayClient {
/// MXAccess session; the resulting native MxStatus is returned in the /// MXAccess session; the resulting native MxStatus is returned in the
/// reply. Acks are idempotent at the MxAccess layer. /// 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 /// # Errors
/// ///
/// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but /// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but
@@ -336,7 +339,9 @@ impl GatewayClient {
&self, &self,
request: AcknowledgeAlarmRequest, request: AcknowledgeAlarmRequest,
) -> Result<AcknowledgeAlarmReply, Error> { ) -> Result<AcknowledgeAlarmReply, Error> {
let secret = request.operator_password.clone();
let mut client = self.inner.clone(); let mut client = self.inner.clone();
let result = async {
let response = client let response = client
.acknowledge_alarm(self.unary_request(request)) .acknowledge_alarm(self.unary_request(request))
.await?; .await?;
@@ -344,6 +349,10 @@ impl GatewayClient {
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?; ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
Ok(reply) 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 /// Open the server-streaming `QueryActiveAlarms` RPC — the gateway's
/// ConditionRefresh equivalent. /// ConditionRefresh equivalent.
+70
View File
@@ -314,6 +314,68 @@ impl std::fmt::Display for MxAccessError {
impl std::error::Error 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 { impl From<tonic::Status> for Error {
fn from(status: tonic::Status) -> Self { fn from(status: tonic::Status) -> Self {
let message = redact_credentials(status.message()); let message = redact_credentials(status.message());
@@ -461,6 +523,14 @@ fn redact_credentials(message: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { 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 tonic::{Code, Status};
use super::{ensure_mxaccess_success, Error}; use super::{ensure_mxaccess_success, Error};
+33 -2
View File
@@ -229,6 +229,8 @@ async fn acknowledge_alarm_returns_reply_with_native_status() {
alarm_full_reference: "Tank01.Level.HiHi".to_owned(), alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
comment: "investigating".to_owned(), comment: "investigating".to_owned(),
operator_user: "alice".to_owned(), operator_user: "alice".to_owned(),
operator_password: String::new(),
operator_domain: String::new(),
}) })
.await .await
.unwrap(); .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] #[tokio::test]
async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_id() { async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_id() {
// The CLI's `stream-alarms` and `acknowledge-alarm` paths used to // 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(), alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
comment: String::new(), comment: String::new(),
operator_user: String::new(), operator_user: String::new(),
operator_password: String::new(),
operator_domain: String::new(),
}) })
.await .await
.unwrap(); .unwrap();
@@ -1424,8 +1452,11 @@ impl MxAccessGateway for FakeGateway {
&self, &self,
request: Request<AcknowledgeAlarmRequest>, request: Request<AcknowledgeAlarmRequest>,
) -> Result<Response<AcknowledgeAlarmReply>, Status> { ) -> Result<Response<AcknowledgeAlarmReply>, Status> {
*self.state.last_correlation_id.lock().await = let request = request.into_inner();
Some(request.into_inner().client_correlation_id); 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 { Ok(Response::new(AcknowledgeAlarmReply {
correlation_id: "corr-1".to_owned(), correlation_id: "corr-1".to_owned(),
protocol_status: Some(ok_status("ack ok")), protocol_status: Some(ok_status("ack ok")),
+85 -35
View File
@@ -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 "current alarms" list because operator hasn't acked it yet) and
`UNACK_ALM` (the alarm is currently active and unacknowledged). `UNACK_ALM` (the alarm is currently active and unacknowledged).
The other states from `eAlmState` (`ACK_RTN`, `ACK_ALM`) would The other states from `eAlmState` (`ACK_RTN`, `ACK_ALM`) would
appear when an ack is performed — `wwAlarmConsumerClass.AlarmAckByGUID` appear when an ack is performed. The alarm-client ack methods
is the method to call. (`AlarmAckByGUID` and friends) do not produce them headless; an `AckMsg`
write does (`AlarmProbeFindings.md`).
### `GetStatistics` AV — unrelated quirk ### `GetStatistics` AV — unrelated quirk
@@ -660,7 +661,9 @@ alarm-consumer surface unblocks A.2 fully. Outline:
events for added/changed/removed records. events for added/changed/removed records.
- `AlarmAckByGUID(VBGUID, comment, oprName, node, domain, - `AlarmAckByGUID(VBGUID, comment, oprName, node, domain,
fullName)` for client-driven acknowledgements (matches 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` + - Lifecycle teardown: `DeregisterConsumer` +
`UninitializeConsumer` + `Marshal.FinalReleaseComObject`. `UninitializeConsumer` + `Marshal.FinalReleaseComObject`.
3. **Conversion layer:** map XML record fields to 3. **Conversion layer:** map XML record fields to
@@ -701,8 +704,9 @@ alarm-consumer surface unblocks A.2 fully. Outline:
`bRetainHiddenAlarms` parameter at all. `bRetainHiddenAlarms` parameter at all.
- Subscription expression format: `\\<machine>\Galaxy!<area>` - Subscription expression format: `\\<machine>\Galaxy!<area>`
(literal `Galaxy` provider) for both libraries. (literal `Galaxy` provider) for both libraries.
- Native ack: `AlarmAckByGUID(VBGUID guid, comment, oprName, - Native ack surface (not used by the gateway; superseded 2026-10-19 by
node, domain, fullName)` on the v2 surface; ID 5-arg 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. variant on the legacy `IwwAlarmConsumer` interface.
These findings retire the open follow-up probes from the 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 the canonical `\\<machine>\Galaxy!<area>` form into the XML's
NODE/PROVIDER/GROUP fields. Mangled or not, the call enables reads. 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 `SetXmlAlarmQuery` enables reads but **breaks `AlarmAckByName` on
the same consumer instance**. With SetXml applied, AlarmAckByName the same consumer instance**. With SetXml applied, AlarmAckByName
returns -55 even with valid name+provider+group+operator. Without returns -55 even with valid name+provider+group+operator. Without
SetXml, AlarmAckByName succeeds with rc=0. SetXml, AlarmAckByName succeeds with rc=0.
The production consumer therefore provisions **two** wnwrap COM The consumer at the time provisioned **two** wnwrap COM
instances: instances:
- Primary consumer (`client`): runs full lifecycle including - Primary consumer (`client`): runs full lifecycle including
`SetXmlAlarmQuery` for `GetXmlCurrentAlarms2` polls. `SetXmlAlarmQuery` for `GetXmlCurrentAlarms2` polls.
- Ack-only consumer (`ackClient`): runs Initialize → Register → - Ack-only consumer (`ackClient`): runs Initialize → Register →
Subscribe via the v1-prefixed methods, **no SetXmlAlarmQuery**. 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 consumers subscribe to the same expression. Disposal cleans up
both via a shared `ReleaseConsumerCom` helper. 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: `wwAlarmConsumerClass` exposes two `AlarmAckByName` overloads:
- `IwwAlarmConsumer2` v2: 8 args (`name, provider, group, comment, - `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 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 operator-identity inputs — looks like a stub. The v1 6-arg method
works. Production `WnWrapAlarmConsumer.AcknowledgeByName` calls the works. `WnWrapAlarmConsumer.AcknowledgeByName` called the
6-arg overload and discards the proto's `domain` + `full_name` fields. 6-arg overload and discarded the proto's `domain` + `full_name` fields.
**Correction, 2026-08-18** (`docs/AlarmProbeFindings.md`). Both halves **Correction, 2026-08-18** (`docs/AlarmProbeFindings.md`). Both halves
of the paragraph above are narrower than they read. The -55 tracks the of the paragraph above are narrower than they read. The -55 tracks the
@@ -775,23 +786,58 @@ snapshot stay `UNACK_ALM`, `OPERATOR_NAME` stay empty, and the alarm
extension's own `.Acked` attribute stay `False` for 16 s after each 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 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. 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` The v2 `AlarmAckByGUID(VBGUID, …)` throws `NotImplementedException`
(COM `E_NOTIMPL`) on `wwAlarmConsumerClass` against this AVEVA (COM `E_NOTIMPL`) on `wwAlarmConsumerClass` against this AVEVA
build. The reference→GUID lookup that we initially planned to wire build. The reference→GUID lookup that we initially planned to wire
through `AlarmAckByGUID` is therefore not viable on wnwrap; all acks through `AlarmAckByGUID` is therefore not viable on wnwrap.
must go through `AlarmAckByName`.
The proto `AcknowledgeAlarmCommand` (GUID-based) and the worker's #### Managed GUID ack experiment — did not work (live-tested 2026-10-18, removed 2026-10-19)
`MxAccessCommandExecutor.ExecuteAcknowledgeAlarm` switch arm remain
in the codebase for the forward-compat shape, but the gateway-side An opt-in experiment drove `aaAlarmManagedClient.AlarmClient` by reflection
`WorkerAlarmRpcDispatcher.AcknowledgeAsync` now always routes through (its members are non-public) to take GUID-keyed acks in place of the wnwrap
`AcknowledgeAlarmByName` when the public RPC supplies a recognizable method. The live test showed it could not work, and the experiment and its
`Provider!Group.Tag` reference. 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 experiment enabled:
| Step | Result |
|---|---|
| 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` |
Three separate problems follow:
1. **The two libraries cannot share a process.** The worker always runs
a wnwrap reader, so the managed `RegisterConsumer` fails with `-65`.
The dispatcher is built to drop back to the wnwrap ack path on a
lifecycle failure (so alarm reads keep working), and it does so
silently — which is why the 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
client holding records. Getting the client's own identifier for the
alarm is not reliable either: `GetHighPriAlarm` mostly throws the
FILETIME `ArgumentOutOfRangeException` described above. The
`MxSecurityOperate` question was therefore never reached.
3. **The gateway does not send the identity fields.** Its GUID arm fills
`OperatorNode`, `OperatorDomain` and `OperatorFullName` with empty
strings, so the domain and full name the managed method carries
would not arrive through the public RPC even if 1 and 2 were solved.
The route was dropped in favour of the authenticated `AckMsg` write
(`AlarmProbeFindings.md`, "Fourth attempt").
### 5. STA / threading — resolved ### 5. STA / threading — resolved
@@ -947,20 +993,24 @@ AVEVA-internal GUID.
### Acknowledge in subtag mode ### 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). - **Alarm-manager mode:** the item is `<TagName>.AckMsg` for Galaxy-provider
- **Subtag mode:** `SubtagAlarmConsumer.AcknowledgeByName` resolves the alarms. An alarm from another provider is not ackable.
watch-list entry's `ack_comment_subtag` and issues a `Write(comment)` on - **Subtag mode:** `SubtagAlarmConsumer` resolves the watch-list entry's
the STA via `LmxSubtagAlarmSource`. Writing the `AckMsg` subtag performs `ack_comment_subtag` and the writer writes that item verbatim. Writing the
the acknowledge in AVEVA (`AckMsg` is the confirmed `AlarmExtension` ack-comment `AckMsg` subtag performs the acknowledge in AVEVA (`AckMsg` is the confirmed
write target). `AlarmExtension` ack-comment write target).
If the alarm has no writable ack-comment subtag (`AckComment` config key is Because the write is authenticated, the Historian records the operator's name,
empty, or the entry's `ack_comment_subtag` field is empty), the ack call domain and full name, and the writer's MXAccess client name
returns a failure code that the gateway surfaces as `FailedPrecondition`. (`MxGateway:Alarms:AckClientName`) as OperatorNode. If the alarm has no ack
`AcknowledgeByGuid` maps the synthetic GUID back to its reference via an target (`AckComment` config key is empty, the entry's `ack_comment_subtag` is
internal dictionary, then calls the same write path. 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 `SubtagAlarmConsumer.Subscribe` advises the ack-comment subtag alongside the
observed ones (active/acked/priority). This is required: MXAccess rejects a observed ones (active/acked/priority). This is required: MXAccess rejects a
@@ -978,7 +1028,7 @@ The subtag path was validated against live MXAccess on the dev rig
`…​.Priority` → `500` (Int32), `…​.AckMsg` → string — confirming the field `…​.Priority` → `500` (Int32), `…​.AckMsg` → string — confirming the field
names **and** the runtime reference shape `<Object>.<AlarmAttr>.<field>` names **and** the runtime reference shape `<Object>.<AlarmAttr>.<field>`
with **no** intermediate alarm-condition segment. 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. was advised — confirming the ack-by-comment-write mechanism end to end.
### Fidelity limitations ### Fidelity limitations
+127 -2
View File
@@ -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 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 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** | | `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 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 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 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` ## 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 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 `UNACK_ALM` and `ACK_ALM` as active, so these are inert for the gateway; they will clear on
the next `alarmmgr` restart. 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.
+1 -1
View File
@@ -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: `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:
+11
View File
@@ -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 failover consumer and returns a normal `MxCommandReply`; callers must treat only
`protocol_status = OK` as acknowledgement of the new list. `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: The command model also includes bulk write/read command kinds:
`WriteBulk`, `Write2Bulk`, `WriteSecuredBulk`, `WriteSecured2Bulk`, and `WriteBulk`, `Write2Bulk`, `WriteSecuredBulk`, `WriteSecured2Bulk`, and
`ReadBulk`. They are unary `Invoke` payloads on the same `MxAccessGateway` `ReadBulk`. They are unary `Invoke` payloads on the same `MxAccessGateway`
+50 -8
View File
@@ -703,14 +703,15 @@ latch in subtag mode.
### Acknowledge via ack-comment write ### Acknowledge via ack-comment write
In subtag mode, `AcknowledgeAlarm` writes the operator comment to the alarm Both provider modes acknowledge by writing the operator comment to `AckMsg` as
attribute's ack-comment subtag (`Fallback:Subtags:AckComment`). The write the authenticated operator (see "Alarm acknowledgement writes AckMsg as the
performs the native ack in AVEVA. This differs from alarmmgr mode, where authenticated operator" below). In alarm-manager mode the target is
`AlarmAckByName` on `wwAlarmConsumerClass` is called directly. The `AckComment` `<TagName>.AckMsg`; in subtag mode it is the watch-list entry's ack-comment
subtag name is empty by default; configuring it is required for ack to work in subtag (`Fallback:Subtags:AckComment`, default `AckMsg`). The write performs
subtag mode. The exact AVEVA subtag names are not hard-coded — the `Subtags` the ack in AVEVA. An entry with an empty ack-comment subtag is not ackable and
config block exists precisely so names are not guessed without validation the RPC reports `InvalidRequest`. The exact AVEVA subtag names are not
against the live MXAccess attribute set. hard-coded — the `Subtags` config block exists so names are not guessed
without validation against the live MXAccess attribute set.
### Related documentation ### Related documentation
@@ -784,6 +785,47 @@ The following items were previously listed here and have since shipped:
`MxGateway:Sessions:MaxEventSubscribersPerSession`. `MxGateway:Sessions:MaxEventSubscribersPerSession`.
See [Event Subscribers](#event-subscribers) above and [Sessions](./Sessions.md). 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 ## Related Documentation
- [Gateway Process Detailed Design](./GatewayProcessDesign.md) - [Gateway Process Detailed Design](./GatewayProcessDesign.md)
+5 -3
View File
@@ -4,7 +4,7 @@ The diagnostics subsystem provides structured logging, credential redaction, and
## Goals ## 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. 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", "AuthenticateUser",
"WriteSecured", "WriteSecured",
"WriteSecured2" "WriteSecured2",
"AcknowledgeAlarm",
"AcknowledgeAlarmByName"
}; };
public static bool IsCredentialBearingCommand(string? commandMethod) 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 ### API key redaction
+10 -1
View File
@@ -442,10 +442,19 @@ 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: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: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: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 The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
`StreamAlarms` are session-less RPCs served by the monitor. `StreamAlarms` are session-less RPCs served by the monitor.
`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 ### Alarm fallback options
The `Fallback` sub-section controls how the alarm feed selects between the The `Fallback` sub-section controls how the alarm feed selects between the
@@ -463,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: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: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: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. | | `MxGateway:Alarms:Fallback:Subtags:Priority` | `Priority` | Subtag name for the alarm priority / severity value. Confirmed AVEVA `AlarmExtension` field name. |
Validation rules: Validation rules:
+24 -2
View File
@@ -96,9 +96,30 @@ Selected control commands have additional public behavior beyond their protobuf
### `AcknowledgeAlarm` ### `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` ### `StreamAlarms`
@@ -217,6 +238,7 @@ receive this event directly.
| `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` | | `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` |
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `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` | `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. | — | | `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. | — | | `QueryActiveAlarms` | No required fields — `alarm_filter_prefix` is optional. Not routed through `MxAccessGrpcRequestValidator`; the handler only null-checks the request. | — |
+17
View File
@@ -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` is bounded: `statuses` is an enrichment field, and `Hresult`/`ProtocolStatus`
stay acceptance-only regardless of which completion (if any) was attributed. 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 ## Live alarm watch-list replacement
An auto-mode `SubscribeAlarms` with `fallback_enabled = true` always builds a An auto-mode `SubscribeAlarms` with `fallback_enabled = true` always builds a
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+22 -18
View File
@@ -140,8 +140,9 @@ one gateway-owned worker session subscribes the configured AVEVA alarm
provider, caches the active-alarm set (reconciled periodically against the provider, caches the active-alarm set (reconciled periodically against the
worker's snapshot), and fans it out to every client through the session-less 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, `StreamAlarms` RPC — the stream opens with the current active-alarm snapshot,
then streams live transitions. `AcknowledgeAlarm` is session-less and routes then streams live transitions. `AcknowledgeAlarm` is session-less, routes
through the monitor. Clients never open a worker session to see alarms, and 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 alarm monitoring is independent of client lifecycle; the monitor re-opens its
session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule `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 itself. The synthesis rules are documented in
`docs/AlarmClientDiscovery.md`. `docs/AlarmClientDiscovery.md`.
**Acknowledge in subtag mode:** the ack-by-name path writes the operator **Acknowledge:** the gateway acknowledges an alarm by writing the operator's
comment to the alarm attribute's ack-comment subtag. The write performs the comment to the alarm attribute's `AckMsg` (subtag mode: the configured
ack. If the attribute has no writable ack-comment subtag configured, the RPC ack-comment subtag) over MXAccess, after `AuthenticateUser` as the requesting
returns `FailedPrecondition`. In alarm-manager mode, `AlarmAckByName` is operator (`operator_user`, `operator_password`, optional `operator_domain`).
used as before. 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
**Acceptance is not application:** the ack is forwarded to the provider, and a OperatorNode is the ack writer's MXAccess client name
successful return means the provider accepted the call, not that the (`MxGateway:Alarms:AckClientName`). The worker waits a bounded time for
acknowledgement was applied. On galaxies whose alarmed attributes carry a `OnWriteComplete`: an OK status is reported as success, a non-OK status as
non-free-access security classification, `AlarmAckByName` returns `rc=0` and the `MxaccessFailure` with the status detail, and no completion as success with a
alarm stays `UNACK_ALM` — it carries an operator name, not an authenticated diagnostic noting the write was sent but unconfirmed. An unknown alarm or one
identity. (Observed on the probe rig; the mechanism is inferred.) Confirm an ack with no ack target (non-Galaxy provider, no ack-comment subtag) is reported as
by the resulting transition, never by the return code. See `InvalidRequest` with `hresult` -1. Alarm-client ack calls
`docs/AlarmProbeFindings.md`. (`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 state visibility:** every subtag-mode transition carries
`degraded = true` and `source_provider = ALARM_PROVIDER_MODE_SUBTAG` on the `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, - gateway validates worker executable path and version,
- no secrets in command line when avoidable. - no secrets in command line when avoidable.
Credential-sensitive commands such as `AuthenticateUser` and `WriteSecured` Credential-sensitive commands such as `AuthenticateUser`, `WriteSecured` and
must not log passwords or raw credential values. `AcknowledgeAlarm` (its `operator_password`) must not log passwords or raw
credential values.
## Observability ## Observability
File diff suppressed because it is too large Load Diff
@@ -391,10 +391,13 @@ message AlarmFailoverConfig {
int32 failback_stable_probes = 3; // clean probes before switching back (>=1) int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
} }
// Acknowledge a single alarm by its GUID. Operator identity fields are // Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
// recorded atomically with the ack transition in the alarm-history log. // alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
// The reply's hresult / native_status surfaces AVEVA's // by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
// AlarmAckByGUID return code. // 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.
message AcknowledgeAlarmCommand { message AcknowledgeAlarmCommand {
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73"). // Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
string alarm_guid = 1; string alarm_guid = 1;
@@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand {
string operator_node = 4; string operator_node = 4;
string operator_domain = 5; string operator_domain = 5;
string operator_full_name = 6; string operator_full_name = 6;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 7;
} }
// Snapshot the currently-active alarm set. Optional filter prefix scopes // Snapshot the currently-active alarm set. Optional filter prefix scopes
@@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand {
// Acknowledge a single alarm by its (name, provider, group) tuple. Used // Acknowledge a single alarm by its (name, provider, group) tuple. Used
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot // when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
// be resolved to a GUID directly. The worker invokes // be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
// history path as AlarmAckByGUID.
message AcknowledgeAlarmByNameCommand { message AcknowledgeAlarmByNameCommand {
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself // Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
// may contain dots; the gateway-side parser splits on the first dot // may contain dots; the gateway-side parser splits on the first dot
@@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand {
string operator_node = 6; string operator_node = 6;
string operator_domain = 7; string operator_domain = 7;
string operator_full_name = 8; string operator_full_name = 8;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 9;
} }
message UnsubscribeBulkCommand { message UnsubscribeBulkCommand {
@@ -725,11 +734,11 @@ message DrainEventsReply {
// Reply payload for AcknowledgeAlarmCommand AND // Reply payload for AcknowledgeAlarmCommand AND
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this // AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
// payload case (`MxCommandReply.acknowledge_alarm`); there is no // payload case (`MxCommandReply.acknowledge_alarm`); there is no
// dedicated by-name reply case. Surfaces AVEVA's native ack return // dedicated by-name reply case. Surfaces the ack outcome: 0 when the
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the // AckMsg write was applied or sent but unconfirmed; otherwise the
// by-name arm); 0 means success. The MxCommandReply's hresult field // AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
// carries the same value and is preferred for protocol consumers — // 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 // can echo native_status into AcknowledgeAlarmReply.hresult without
// unpacking the outer envelope. // unpacking the outer envelope.
message AcknowledgeAlarmReplyPayload { message AcknowledgeAlarmReplyPayload {
@@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest {
// Operator principal performing the acknowledgement. The lmxopcua side // Operator principal performing the acknowledgement. The lmxopcua side
// resolves this from the OPC UA session prior to invoking the RPC. // resolves this from the OPC UA session prior to invoking the RPC.
string operator_user = 5; string operator_user = 5;
// 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.
string operator_password = 6;
// Optional domain for OS-authenticated galaxies. When set the gateway
// authenticates as "<operator_domain>\<operator_user>".
string operator_domain = 7;
} }
message AcknowledgeAlarmReply { message AcknowledgeAlarmReply {
@@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply {
ProtocolStatus protocol_status = 3; ProtocolStatus protocol_status = 3;
// Native ack return code echoed from the worker. The worker carries the // Native ack return code echoed from the worker. The worker carries the
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status, // ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's // 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
// ack-outcome field for the public RPC. Absent only when the worker reply // ack-outcome field for the public RPC. Absent only when the worker reply
// omitted the value entirely (a protocol violation). // omitted the value entirely (a protocol violation).
optional int32 hresult = 4; optional int32 hresult = 4;
@@ -22,4 +22,10 @@ public static class WorkerLaunchEnvironment
/// <summary>Maximum alarm records requested in one current-alarm fetch.</summary> /// <summary>Maximum alarm records requested in one current-alarm fetch.</summary>
public const string MaxAlarmsPerFetch = "MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH"; public const string MaxAlarmsPerFetch = "MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
/// <summary>
/// MXAccess client name the worker's alarm-ack writer registers with. The
/// Historian records it as the acknowledgement's OperatorNode.
/// </summary>
public const string AlarmAckClientName = "MXGATEWAY_ALARM_ACK_CLIENT_NAME";
} }
@@ -1169,7 +1169,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
return string.Empty; return string.Empty;
} }
private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError) internal static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
{ {
parseError = null; parseError = null;
if (string.IsNullOrWhiteSpace(request.AlarmFullReference)) if (string.IsNullOrWhiteSpace(request.AlarmFullReference))
@@ -1180,6 +1180,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
string comment = request.Comment ?? string.Empty; string comment = request.Comment ?? string.Empty;
string operatorUser = request.OperatorUser ?? 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)) if (Guid.TryParse(request.AlarmFullReference, out Guid guid))
{ {
@@ -1192,7 +1194,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
Comment = comment, Comment = comment,
OperatorUser = operatorUser, OperatorUser = operatorUser,
OperatorNode = string.Empty, OperatorNode = string.Empty,
OperatorDomain = string.Empty, OperatorDomain = operatorDomain,
OperatorPassword = operatorPassword,
OperatorFullName = string.Empty, OperatorFullName = string.Empty,
}, },
}; };
@@ -1211,7 +1214,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
Comment = comment, Comment = comment,
OperatorUser = operatorUser, OperatorUser = operatorUser,
OperatorNode = string.Empty, OperatorNode = string.Empty,
OperatorDomain = string.Empty, OperatorDomain = operatorDomain,
OperatorPassword = operatorPassword,
OperatorFullName = string.Empty, OperatorFullName = string.Empty,
}, },
}; };
@@ -73,6 +73,13 @@ public sealed class AlarmsOptions
/// </summary> /// </summary>
public int MaxAlarmsPerFetch { get; init; } = 1024; 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> /// <summary>
/// Configuration for the alarm-manager ↔ subtag fallback mechanism: /// Configuration for the alarm-manager ↔ subtag fallback mechanism:
/// operating mode, failure-detection thresholds, discovery, and subtag /// 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}."); $"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) if (!options.Enabled)
{ {
return; return;
@@ -12,7 +12,10 @@ public static class GatewayLogRedactor
{ {
"AuthenticateUser", "AuthenticateUser",
"WriteSecured", "WriteSecured",
"WriteSecured2" "WriteSecured2",
// Carry operator_password.
"AcknowledgeAlarm",
"AcknowledgeAlarmByName"
}; };
/// <summary> /// <summary>
@@ -33,6 +33,14 @@ public sealed class GatewayLogRedactorSeam : ILogRedactor
} }
RedactCommandValue(properties); 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> /// <summary>
@@ -197,6 +197,11 @@ public sealed class MxAccessGatewayService(
throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required.")); 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) return await alarmService.AcknowledgeAsync(request, context.CancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@@ -223,6 +223,8 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
startInfo.Environment[WorkerLaunchEnvironment.MaxAlarmsPerFetch] = startInfo.Environment[WorkerLaunchEnvironment.MaxAlarmsPerFetch] =
_alarmsOptions.MaxAlarmsPerFetch.ToString(System.Globalization.CultureInfo.InvariantCulture); _alarmsOptions.MaxAlarmsPerFetch.ToString(System.Globalization.CultureInfo.InvariantCulture);
startInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName] = _alarmsOptions.AckClientName;
commandLine = new WorkerProcessCommandLine(executablePath, arguments); commandLine = new WorkerProcessCommandLine(executablePath, arguments);
return startInfo; return startInfo;
@@ -32,6 +32,9 @@
native 2.1.11; 2.1.12 patches it. Bumping Sqlite does not clear it. --> 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="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> <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" /> <PackageReference Include="Polly.Core" Version="8.6.6" />
</ItemGroup> </ItemGroup>
@@ -88,7 +88,8 @@
"DefaultArea": "", "DefaultArea": "",
"ReconcileIntervalSeconds": 30, "ReconcileIntervalSeconds": 30,
"PollIntervalMilliseconds": 500, "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, 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> /// <summary>Verifies the alarm poll cadence and per-fetch cap defaults pass validation.</summary>
[Fact] [Fact]
public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap() public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap()
@@ -310,11 +310,47 @@ public sealed class ProtobufContractRoundTripTests
AlarmFullReference = "Tank01.Level.HiHi", AlarmFullReference = "Tank01.Level.HiHi",
Comment = "shift handover", Comment = "shift handover",
OperatorUser = "operator2", OperatorUser = "operator2",
OperatorPassword = "s3cret",
OperatorDomain = "CORP",
}; };
var parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray()); var parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed); Assert.Equal(original, parsed);
Assert.Equal("s3cret", parsed.OperatorPassword);
Assert.Equal("CORP", parsed.OperatorDomain);
}
/// <summary>Verifies that the worker ack commands round-trip the operator password.</summary>
[Fact]
public void AcknowledgeAlarmCommands_RoundTripOperatorPassword()
{
MxCommand byGuid = new()
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
OperatorUser = "alice",
OperatorDomain = "CORP",
OperatorPassword = "s3cret",
},
};
MxCommand byName = new()
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = "TestMachine_001.TestAlarm001",
ProviderName = "Galaxy",
GroupName = "TestArea",
OperatorUser = "alice",
OperatorPassword = "s3cret",
},
};
Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byGuid.ToByteArray()).AcknowledgeAlarmCommand.OperatorPassword);
Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byName.ToByteArray()).AcknowledgeAlarmByNameCommand.OperatorPassword);
} }
/// <summary>Verifies that AcknowledgeAlarmReply round-trips with status, hresult, and diagnostics.</summary> /// <summary>Verifies that AcknowledgeAlarmReply round-trips with status, hresult, and diagnostics.</summary>
@@ -84,4 +84,23 @@ public sealed class GatewayLogRedactorSeamTests
Assert.False(props.ContainsKey("CommandValue")); 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("AuthenticateUser")]
[InlineData("WriteSecured")] [InlineData("WriteSecured")]
[InlineData("WriteSecured2")] [InlineData("WriteSecured2")]
[InlineData("AcknowledgeAlarm")]
[InlineData("AcknowledgeAlarmByName")]
public void IsCredentialBearingCommand_IdentifiesSensitiveMxAccessCommands(string commandMethod) public void IsCredentialBearingCommand_IdentifiesSensitiveMxAccessCommands(string commandMethod)
{ {
Assert.True(GatewayLogRedactor.IsCredentialBearingCommand(commandMethod)); Assert.True(GatewayLogRedactor.IsCredentialBearingCommand(commandMethod));
@@ -2,6 +2,7 @@ using Google.Protobuf.WellKnownTypes;
using Grpc.Core; using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Xunit.Abstractions;
using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -21,7 +22,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
/// the fake worker harness. Covers fan-out to two concurrent subscribers, independent /// the fake worker harness. Covers fan-out to two concurrent subscribers, independent
/// cancellation isolation, and the per-session subscriber cap. /// cancellation isolation, and the per-session subscriber cap.
/// </summary> /// </summary>
public sealed class GatewayEndToEndMultiSubscriberTests public sealed class GatewayEndToEndMultiSubscriberTests(ITestOutputHelper output)
{ {
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10);
private const int ServerHandle = 3001; private const int ServerHandle = 3001;
@@ -37,7 +38,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
public async Task StreamEvents_TwoSubscribers_BothReceiveAllEvents() public async Task StreamEvents_TwoSubscribers_BothReceiveAllEvents()
{ {
MultiEventFakeWorkerProcessLauncher launcher = new(EventCount); MultiEventFakeWorkerProcessLauncher launcher = new(EventCount);
await using MultiSubscriberGatewayServiceFixture fixture = new(launcher); await using MultiSubscriberGatewayServiceFixture fixture = new(launcher, diagnosticOutput: output.WriteLine);
OpenSessionReply openReply = await fixture.Service.OpenSession( OpenSessionReply openReply = await fixture.Service.OpenSession(
new OpenSessionRequest new OpenSessionRequest
@@ -136,7 +137,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
public async Task StreamEvents_OneSubscriberCancels_OtherContinuesReceivingEvents() public async Task StreamEvents_OneSubscriberCancels_OtherContinuesReceivingEvents()
{ {
GatedEventFakeWorkerProcessLauncher launcher = new(); GatedEventFakeWorkerProcessLauncher launcher = new();
await using MultiSubscriberGatewayServiceFixture fixture = new(launcher); await using MultiSubscriberGatewayServiceFixture fixture = new(launcher, diagnosticOutput: output.WriteLine);
OpenSessionReply openReply = await fixture.Service.OpenSession( OpenSessionReply openReply = await fixture.Service.OpenSession(
new OpenSessionRequest new OpenSessionRequest
@@ -254,7 +255,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
// (EventSubscriberLimitReached) is only reachable when multi-subscriber mode is on. // (EventSubscriberLimitReached) is only reachable when multi-subscriber mode is on.
// Without it the service hits EventSubscriberAlreadyActive instead — also // Without it the service hits EventSubscriberAlreadyActive instead — also
// ResourceExhausted, but for the wrong reason — masking a misconfiguration. // ResourceExhausted, but for the wrong reason — masking a misconfiguration.
await using MultiSubscriberGatewayServiceFixture fixture = new(launcher, maxEventSubscribersPerSession: cap); await using MultiSubscriberGatewayServiceFixture fixture = new(launcher, maxEventSubscribersPerSession: cap, diagnosticOutput: output.WriteLine);
OpenSessionReply openReply = await fixture.Service.OpenSession( OpenSessionReply openReply = await fixture.Service.OpenSession(
new OpenSessionRequest new OpenSessionRequest
@@ -413,20 +414,25 @@ public sealed class GatewayEndToEndMultiSubscriberTests
{ {
private readonly GatewayMetrics _metrics = new(); private readonly GatewayMetrics _metrics = new();
private readonly SessionRegistry _registry = new(); private readonly SessionRegistry _registry = new();
private readonly FakeWorkerDiagnostics _diagnostics;
/// <summary>Initializes a new instance of the <see cref="MultiSubscriberGatewayServiceFixture"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="MultiSubscriberGatewayServiceFixture"/> class.</summary>
/// <param name="launcher">Fake worker process launcher backing the session manager.</param> /// <param name="launcher">Fake worker process launcher backing the session manager.</param>
/// <param name="diagnosticOutput">Safe metadata output sink.</param>
/// <param name="maxEventSubscribersPerSession">Maximum concurrent event subscribers allowed per session.</param> /// <param name="maxEventSubscribersPerSession">Maximum concurrent event subscribers allowed per session.</param>
public MultiSubscriberGatewayServiceFixture( public MultiSubscriberGatewayServiceFixture(
IWorkerProcessLauncher launcher, IWorkerProcessLauncher launcher,
int maxEventSubscribersPerSession = 8) int maxEventSubscribersPerSession = 8,
Action<string>? diagnosticOutput = null)
{ {
_diagnostics = ((IDiagnosticFakeWorker)launcher).Diagnostics;
_diagnostics.SetOutput(diagnosticOutput);
IOptions<GatewayOptions> options = Options.Create(CreateOptions(maxEventSubscribersPerSession)); IOptions<GatewayOptions> options = Options.Create(CreateOptions(maxEventSubscribersPerSession));
SessionWorkerClientFactory workerClientFactory = new( SessionWorkerClientFactory workerClientFactory = new(
launcher, launcher,
options, options,
_metrics, _metrics,
NullLoggerFactory.Instance); _diagnostics);
SessionManager sessionManager = new( SessionManager sessionManager = new(
_registry, _registry,
workerClientFactory, workerClientFactory,
@@ -500,12 +506,19 @@ public sealed class GatewayEndToEndMultiSubscriberTests
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
foreach (GatewaySession session in _registry.Snapshot()) // Session disposal and worker observation share the existing test budget.
{ // The observer consumes a late cleanup fault without replacing the primary failure.
await session.DisposeAsync(); _diagnostics.Track(DisposeSessionsAsync(), "teardown");
await _diagnostics.DrainAsync(TestTimeout).ConfigureAwait(false);
_metrics.Dispose();
} }
_metrics.Dispose(); private async Task DisposeSessionsAsync()
{
foreach (GatewaySession session in _registry.Snapshot())
{
await session.DisposeAsync().ConfigureAwait(false);
}
} }
private static GatewayOptions CreateOptions(int maxEventSubscribersPerSession) => private static GatewayOptions CreateOptions(int maxEventSubscribersPerSession) =>
@@ -540,7 +553,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
/// command, then waits for shutdown. Events carry an indexed string value so the test can /// command, then waits for shutdown. Events carry an indexed string value so the test can
/// verify fan-out order across two subscribers. /// verify fan-out order across two subscribers.
/// </summary> /// </summary>
private sealed class MultiEventFakeWorkerProcessLauncher(int eventCount) : IWorkerProcessLauncher private sealed class MultiEventFakeWorkerProcessLauncher(int eventCount) : IWorkerProcessLauncher, IDiagnosticFakeWorker
{ {
public const int ProcessId = 7710; public const int ProcessId = 7710;
@@ -549,12 +562,16 @@ public sealed class GatewayEndToEndMultiSubscriberTests
/// <summary>Gets the task representing the fake worker's running background loop.</summary> /// <summary>Gets the task representing the fake worker's running background loop.</summary>
public Task WorkerTask { get; private set; } = Task.CompletedTask; public Task WorkerTask { get; private set; } = Task.CompletedTask;
/// <inheritdoc />
public FakeWorkerDiagnostics Diagnostics { get; } = new();
/// <inheritdoc /> /// <inheritdoc />
public Task<WorkerProcessHandle> LaunchAsync( public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request, WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
WorkerTask = RunWorkerAsync(request, cancellationToken); WorkerTask = RunWorkerAsync(request, cancellationToken);
Diagnostics.Track(WorkerTask);
return Task.FromResult(new WorkerProcessHandle( return Task.FromResult(new WorkerProcessHandle(
_process, _process,
@@ -578,6 +595,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
{ {
WorkerEnvelope envelope = await harness.ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); WorkerEnvelope envelope = await harness.ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
Diagnostics.Received(envelope);
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown) if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown)
{ {
await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false); await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -630,7 +648,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
/// deterministic. Call <see cref="StopEmitting"/> before closing the session so the /// deterministic. Call <see cref="StopEmitting"/> before closing the session so the
/// worker loop exits cleanly and can process the shutdown envelope. /// worker loop exits cleanly and can process the shutdown envelope.
/// </summary> /// </summary>
private sealed class GatedEventFakeWorkerProcessLauncher : IWorkerProcessLauncher private sealed class GatedEventFakeWorkerProcessLauncher : IWorkerProcessLauncher, IDiagnosticFakeWorker
{ {
public const int ProcessId = 7720; public const int ProcessId = 7720;
@@ -643,6 +661,9 @@ public sealed class GatewayEndToEndMultiSubscriberTests
/// <summary>Gets the task representing the fake worker's running background loop.</summary> /// <summary>Gets the task representing the fake worker's running background loop.</summary>
public Task WorkerTask { get; private set; } = Task.CompletedTask; public Task WorkerTask { get; private set; } = Task.CompletedTask;
/// <inheritdoc />
public FakeWorkerDiagnostics Diagnostics { get; } = new();
/// <summary>Releases the gate so the worker emits one event.</summary> /// <summary>Releases the gate so the worker emits one event.</summary>
public void AllowNextEvent() => _emitGate.Release(); public void AllowNextEvent() => _emitGate.Release();
@@ -662,6 +683,7 @@ public sealed class GatewayEndToEndMultiSubscriberTests
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
WorkerTask = RunWorkerAsync(request, cancellationToken); WorkerTask = RunWorkerAsync(request, cancellationToken);
Diagnostics.Track(WorkerTask);
return Task.FromResult(new WorkerProcessHandle( return Task.FromResult(new WorkerProcessHandle(
_process, _process,
@@ -685,19 +707,19 @@ public sealed class GatewayEndToEndMultiSubscriberTests
int advisedItemHandle = 0; int advisedItemHandle = 0;
int emittedCount = 0; int emittedCount = 0;
// Read envelopes one at a time. Between each envelope, if we have a await using GatedWorkerEnvelopeReader incoming = new(
// subscription and the gate is ready, emit an event before reading the harness.ReadGatewayEnvelopeAsync, _emitGate, cancellationToken);
// next envelope. When _stopEmitting is set, drain the gate and read
// remaining envelopes (including shutdown) without emitting.
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{ {
// While subscribed and not stopped, try to emit gated events using WorkerEnvelope? envelope = await incoming.ReadOrGateAsync(
// a short-timeout peek at the gate — yield to incoming envelopes to advisedServerHandle != 0 && !_stopEmitting).ConfigureAwait(false);
// avoid starving shutdown processing. if (envelope is null)
while (advisedServerHandle != 0
&& !_stopEmitting
&& await _emitGate.WaitAsync(millisecondsTimeout: 0).ConfigureAwait(false))
{ {
if (_stopEmitting)
{
continue;
}
int index = ++emittedCount; int index = ++emittedCount;
await harness.EmitEventAsync( await harness.EmitEventAsync(
MxEventFamily.OnDataChange, MxEventFamily.OnDataChange,
@@ -714,23 +736,10 @@ public sealed class GatewayEndToEndMultiSubscriberTests
}; };
mxEvent.OnDataChange = new OnDataChangeEvent(); mxEvent.OnDataChange = new OnDataChangeEvent();
}).ConfigureAwait(false); }).ConfigureAwait(false);
}
// Use a short timeout so the emit loop above is re-evaluated
// periodically — but long enough not to spam.
WorkerEnvelope? envelope = null;
try
{
using CancellationTokenSource readCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
readCts.CancelAfter(TimeSpan.FromMilliseconds(50));
envelope = await harness.ReadGatewayEnvelopeAsync(readCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Timed out waiting for an envelope — loop back to check gate / emit.
continue; continue;
} }
Diagnostics.Received(envelope);
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown) if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown)
{ {
await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false); await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -1,8 +1,10 @@
using System.Collections.Concurrent;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Grpc.Core; using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing; using Microsoft.Extensions.Time.Testing;
using Xunit.Abstractions;
using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -36,7 +38,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
/// per session and survives detach, so events emitted while no subscriber is attached are /// per session and survives detach, so events emitted while no subscriber is attached are
/// retained for the reconnecting stream. /// retained for the reconnecting stream.
/// </remarks> /// </remarks>
public sealed class GatewayEndToEndReconnectReplayTests public sealed class GatewayEndToEndReconnectReplayTests(ITestOutputHelper output)
{ {
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10);
private const int ServerHandle = 3001; private const int ServerHandle = 3001;
@@ -58,7 +60,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
GatedEventFakeWorkerProcessLauncher launcher = new(); GatedEventFakeWorkerProcessLauncher launcher = new();
// Capacity 16 retains every event emitted here (7 total), so nothing is evicted and a // Capacity 16 retains every event emitted here (7 total), so nothing is evicted and a
// resume from a middle cursor never produces a gap. // resume from a middle cursor never produces a gap.
await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: 16); await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: 16, diagnosticOutput: output.WriteLine);
string sessionId = await OpenSessionAsync(fixture, "reconnect-no-gap"); string sessionId = await OpenSessionAsync(fixture, "reconnect-no-gap");
@@ -157,7 +159,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
GatedEventFakeWorkerProcessLauncher launcher = new(); GatedEventFakeWorkerProcessLauncher launcher = new();
// Small capacity forces eviction: with 6 events emitted and a 3-slot ring, the first 3 // Small capacity forces eviction: with 6 events emitted and a 3-slot ring, the first 3
// are evicted and only the newest 3 remain replayable. // are evicted and only the newest 3 remain replayable.
await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: capacity); await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: capacity, diagnosticOutput: output.WriteLine);
string sessionId = await OpenSessionAsync(fixture, "reconnect-gap"); string sessionId = await OpenSessionAsync(fixture, "reconnect-gap");
@@ -256,7 +258,8 @@ public sealed class GatewayEndToEndReconnectReplayTests
launcher, launcher,
replayBufferCapacity: 16, replayBufferCapacity: 16,
replayRetentionSeconds: retentionSeconds, replayRetentionSeconds: retentionSeconds,
timeProvider: clock); timeProvider: clock,
diagnosticOutput: output.WriteLine);
string sessionId = await OpenSessionAsync(fixture, "reconnect-aged-out"); string sessionId = await OpenSessionAsync(fixture, "reconnect-aged-out");
@@ -340,6 +343,34 @@ public sealed class GatewayEndToEndReconnectReplayTests
$"Live event {delivered.WorkerSequence} must be newer than the highest previously seen {highestSeen}."); $"Live event {delivered.WorkerSequence} must be newer than the highest previously seen {highestSeen}.");
} }
/// <summary>Setup failures preserve gateway EOF and report the original fake worker fault safely.</summary>
/// <param name="faultCommand">The setup command where the fake worker fails.</param>
/// <returns>The asynchronous test operation.</returns>
[Theory]
[InlineData(MxCommandKind.Register)]
[InlineData(MxCommandKind.AddItem)]
public async Task Setup_WhenFakeWorkerFaults_ReportsFaultWithoutMaskingRpcFailure(MxCommandKind faultCommand)
{
ConcurrentQueue<string> output = new();
GatedEventFakeWorkerProcessLauncher launcher = new(faultCommand);
Exception? failure = await Record.ExceptionAsync(async () =>
{
await using ReconnectReplayGatewayServiceFixture fixture = new(
launcher, replayBufferCapacity: 16, diagnosticOutput: output.Enqueue);
string sessionId = await OpenSessionAsync(fixture, "injected-fault");
await WireUpAdviseAsync(fixture, sessionId);
});
RpcException rpc = Assert.IsType<RpcException>(failure);
Assert.Equal(StatusCode.Unavailable, rpc.StatusCode);
Assert.Contains("Worker pipe disconnected", rpc.Status.Detail);
Assert.Contains(output, line => line.Contains("source=fake-worker")
&& line.Contains("type=WorkerFrameProtocolException") && line.Contains("code=InvalidEnvelope")
&& line.Contains($"command={faultCommand}") && line.Contains("phase=command"));
Assert.Contains(output, line => line.Contains("source=gateway") && line.Contains("code=EndOfStream"));
Assert.DoesNotContain(output, line => line.Contains("secret-item") || line.Contains("Galaxy.Tag.Value"));
}
// ---- shared flow helpers ---- // ---- shared flow helpers ----
private static async Task<string> OpenSessionAsync( private static async Task<string> OpenSessionAsync(
@@ -490,6 +521,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
{ {
private readonly GatewayMetrics _metrics = new(); private readonly GatewayMetrics _metrics = new();
private readonly SessionRegistry _registry = new(); private readonly SessionRegistry _registry = new();
private readonly FakeWorkerDiagnostics _diagnostics;
/// <summary>Initializes a new instance of the <see cref="ReconnectReplayGatewayServiceFixture"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="ReconnectReplayGatewayServiceFixture"/> class.</summary>
/// <param name="launcher">Fake worker process launcher backing the session manager.</param> /// <param name="launcher">Fake worker process launcher backing the session manager.</param>
@@ -499,6 +531,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
/// duration of a fast test; the age-eviction test shortens it and drives /// duration of a fast test; the age-eviction test shortens it and drives
/// <paramref name="timeProvider"/>. /// <paramref name="timeProvider"/>.
/// </param> /// </param>
/// <param name="diagnosticOutput">Safe metadata output sink.</param>
/// <param name="timeProvider"> /// <param name="timeProvider">
/// Clock handed to the session manager, which flows through to the session's event /// Clock handed to the session manager, which flows through to the session's event
/// distributor. Pass a fake to make age-eviction deterministic. /// distributor. Pass a fake to make age-eviction deterministic.
@@ -507,15 +540,18 @@ public sealed class GatewayEndToEndReconnectReplayTests
IWorkerProcessLauncher launcher, IWorkerProcessLauncher launcher,
int replayBufferCapacity, int replayBufferCapacity,
double replayRetentionSeconds = 300, double replayRetentionSeconds = 300,
TimeProvider? timeProvider = null) TimeProvider? timeProvider = null,
Action<string>? diagnosticOutput = null)
{ {
_diagnostics = ((IDiagnosticFakeWorker)launcher).Diagnostics;
_diagnostics.SetOutput(diagnosticOutput);
IOptions<GatewayOptions> options = Options.Create( IOptions<GatewayOptions> options = Options.Create(
CreateOptions(replayBufferCapacity, replayRetentionSeconds)); CreateOptions(replayBufferCapacity, replayRetentionSeconds));
SessionWorkerClientFactory workerClientFactory = new( SessionWorkerClientFactory workerClientFactory = new(
launcher, launcher,
options, options,
_metrics, _metrics,
NullLoggerFactory.Instance); _diagnostics);
SessionManager sessionManager = new( SessionManager sessionManager = new(
_registry, _registry,
workerClientFactory, workerClientFactory,
@@ -585,13 +621,20 @@ public sealed class GatewayEndToEndReconnectReplayTests
/// <summary>Disposes every session in the registry and releases the fixture's metrics.</summary> /// <summary>Disposes every session in the registry and releases the fixture's metrics.</summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{
// Session disposal and worker observation share the existing test budget.
// The observer consumes a late cleanup fault without replacing the primary failure.
_diagnostics.Track(DisposeSessionsAsync(), "teardown");
await _diagnostics.DrainAsync(TestTimeout).ConfigureAwait(false);
_metrics.Dispose();
}
private async Task DisposeSessionsAsync()
{ {
foreach (GatewaySession session in _registry.Snapshot()) foreach (GatewaySession session in _registry.Snapshot())
{ {
await session.DisposeAsync().ConfigureAwait(false); await session.DisposeAsync().ConfigureAwait(false);
} }
_metrics.Dispose();
} }
private static GatewayOptions CreateOptions(int replayBufferCapacity, double replayRetentionSeconds) => private static GatewayOptions CreateOptions(int replayBufferCapacity, double replayRetentionSeconds) =>
@@ -642,7 +685,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
/// <see cref="StopEmitting"/> before closing the session so the loop exits cleanly and can /// <see cref="StopEmitting"/> before closing the session so the loop exits cleanly and can
/// process the shutdown envelope. /// process the shutdown envelope.
/// </summary> /// </summary>
private sealed class GatedEventFakeWorkerProcessLauncher : IWorkerProcessLauncher private sealed class GatedEventFakeWorkerProcessLauncher(MxCommandKind? faultCommand = null) : IWorkerProcessLauncher, IDiagnosticFakeWorker
{ {
public const int ProcessId = 7730; public const int ProcessId = 7730;
@@ -655,6 +698,9 @@ public sealed class GatewayEndToEndReconnectReplayTests
/// <summary>Gets the task representing the fake worker's running background loop.</summary> /// <summary>Gets the task representing the fake worker's running background loop.</summary>
public Task WorkerTask { get; private set; } = Task.CompletedTask; public Task WorkerTask { get; private set; } = Task.CompletedTask;
/// <inheritdoc />
public FakeWorkerDiagnostics Diagnostics { get; } = new();
/// <summary>Releases the gate so the worker emits one event.</summary> /// <summary>Releases the gate so the worker emits one event.</summary>
public void AllowNextEvent() => _emitGate.Release(); public void AllowNextEvent() => _emitGate.Release();
@@ -674,6 +720,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
WorkerTask = RunWorkerAsync(request, cancellationToken); WorkerTask = RunWorkerAsync(request, cancellationToken);
Diagnostics.Track(WorkerTask);
return Task.FromResult(new WorkerProcessHandle( return Task.FromResult(new WorkerProcessHandle(
_process, _process,
@@ -697,14 +744,19 @@ public sealed class GatewayEndToEndReconnectReplayTests
int advisedItemHandle = 0; int advisedItemHandle = 0;
int emittedCount = 0; int emittedCount = 0;
await using GatedWorkerEnvelopeReader incoming = new(
harness.ReadGatewayEnvelopeAsync, _emitGate, cancellationToken);
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{ {
// While subscribed and not stopped, emit gated events using a non-blocking peek WorkerEnvelope? envelope = await incoming.ReadOrGateAsync(
// at the gate so incoming envelopes (including shutdown) are never starved. advisedServerHandle != 0 && !_stopEmitting).ConfigureAwait(false);
while (advisedServerHandle != 0 if (envelope is null)
&& !_stopEmitting
&& await _emitGate.WaitAsync(millisecondsTimeout: 0).ConfigureAwait(false))
{ {
if (_stopEmitting)
{
continue;
}
int index = ++emittedCount; int index = ++emittedCount;
await harness.EmitEventAsync( await harness.EmitEventAsync(
MxEventFamily.OnDataChange, MxEventFamily.OnDataChange,
@@ -721,21 +773,10 @@ public sealed class GatewayEndToEndReconnectReplayTests
}; };
mxEvent.OnDataChange = new OnDataChangeEvent(); mxEvent.OnDataChange = new OnDataChangeEvent();
}).ConfigureAwait(false); }).ConfigureAwait(false);
}
WorkerEnvelope? envelope;
try
{
using CancellationTokenSource readCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
readCts.CancelAfter(TimeSpan.FromMilliseconds(50));
envelope = await harness.ReadGatewayEnvelopeAsync(readCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Timed out waiting for an envelope — loop back to check the gate / emit.
continue; continue;
} }
Diagnostics.Received(envelope);
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown) if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown)
{ {
await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false); await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -749,6 +790,11 @@ public sealed class GatewayEndToEndReconnectReplayTests
} }
MxCommand command = envelope.WorkerCommand.Command; MxCommand command = envelope.WorkerCommand.Command;
if (command.Kind == faultCommand)
{
throw new WorkerFrameProtocolException(WorkerFrameProtocolErrorCode.InvalidEnvelope, "secret-item");
}
await harness.ReplyToCommandAsync( await harness.ReplyToCommandAsync(
envelope, envelope,
configureReply: reply => ConfigureCommandReply(reply, command.Kind), configureReply: reply => ConfigureCommandReply(reply, command.Kind),
@@ -389,6 +389,22 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); 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> /// <summary>Verifies AcknowledgeAlarm delegates a valid request to the alarm service.</summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
@@ -478,24 +478,42 @@ public sealed class SessionManagerTests
[Fact] [Fact]
public async Task InvokeAsync_WhenWorkerHandshakingThenReadyWithinTimeout_Succeeds() public async Task InvokeAsync_WhenWorkerHandshakingThenReadyWithinTimeout_Succeeds()
{ {
FakeTimeProvider clock = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
FakeWorkerClient workerClient = new() { State = WorkerClientState.Handshaking }; FakeWorkerClient workerClient = new() { State = WorkerClientState.Handshaking };
SessionManager manager = CreateManager( SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient), new FakeSessionWorkerClientFactory(workerClient),
options: CreateOptions(workerReadyWaitTimeoutMs: 500)); options: CreateOptions(workerReadyWaitTimeoutMs: 500),
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None); GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
Assert.Equal(SessionState.Ready, session.State); Assert.Equal(SessionState.Ready, session.State);
// Flip the worker to Ready shortly after the invoke starts waiting. TaskCompletionSource handshakingObserved = new(TaskCreationOptions.RunContinuationsAsynchronously);
_ = Task.Run(async () => TaskCompletionSource allowReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
workerClient.OnStateRead = state =>
{ {
await Task.Delay(50, CancellationToken.None); if (state == WorkerClientState.Handshaking)
workerClient.State = WorkerClientState.Ready; {
}); handshakingObserved.TrySetResult();
}
WorkerCommandReply reply = await manager.InvokeAsync( };
Task transition = TransitionAsync();
Task<WorkerCommandReply> invoke = manager.InvokeAsync(
session.SessionId, session.SessionId,
CreateCommand(MxCommandKind.Ping), CreateCommand(MxCommandKind.Ping),
CancellationToken.None); CancellationToken.None);
await handshakingObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(invoke.IsCompleted);
Assert.Equal(0, workerClient.InvokeCount);
allowReady.SetResult();
await transition.WaitAsync(TimeSpan.FromSeconds(5));
clock.Advance(TimeSpan.FromMilliseconds(50));
WorkerCommandReply reply = await invoke.WaitAsync(TimeSpan.FromSeconds(5));
async Task TransitionAsync()
{
await allowReady.Task;
workerClient.State = WorkerClientState.Ready;
}
Assert.NotNull(reply); Assert.NotNull(reply);
Assert.Equal(1, workerClient.InvokeCount); Assert.Equal(1, workerClient.InvokeCount);
@@ -1406,14 +1424,28 @@ public sealed class SessionManagerTests
private sealed class FakeWorkerClient : IWorkerClient private sealed class FakeWorkerClient : IWorkerClient
{ {
private WorkerClientState _state = WorkerClientState.Ready;
/// <inheritdoc /> /// <inheritdoc />
public string SessionId { get; init; } = "session-1"; public string SessionId { get; init; } = "session-1";
/// <inheritdoc /> /// <inheritdoc />
public int? ProcessId { get; init; } = 1234; public int? ProcessId { get; init; } = 1234;
/// <summary>Gets or sets the optional state-observation gate.</summary>
public Action<WorkerClientState>? OnStateRead { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public WorkerClientState State { get; set; } = WorkerClientState.Ready; public WorkerClientState State
{
get
{
OnStateRead?.Invoke(_state);
return _state;
}
set => _state = value;
}
/// <inheritdoc /> /// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
@@ -0,0 +1,88 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers;
/// <summary>Protects bounded teardown, fault observation, and safe diagnostic output.</summary>
public sealed class FakeWorkerDiagnosticsTests
{
/// <summary>Repeated gateway logs remain bounded and never format sensitive state or messages.</summary>
[Fact]
public void Log_WhenFaultsRepeat_ReportsOnlyBoundedMetadata()
{
using FakeWorkerDiagnostics diagnostics = new();
List<string> output = [];
diagnostics.SetOutput(output.Add);
for (int i = 0; i < 32; i++)
{
diagnostics.Log(LogLevel.Warning, new EventId(1), "secret-command",
new WorkerFrameProtocolException(WorkerFrameProtocolErrorCode.InvalidEnvelope, "secret-item"),
(state, exception) => $"{state} {exception}");
}
Assert.NotEmpty(output);
Assert.InRange(output.Count, 1, 16);
Assert.All(output, line =>
{
Assert.Contains("type=WorkerFrameProtocolException", line);
Assert.Contains("code=InvalidEnvelope", line);
Assert.DoesNotContain("secret", line);
});
}
/// <summary>A late fault is observed after timeout without calling the completed output sink.</summary>
/// <returns>The asynchronous test operation.</returns>
[Fact]
public async Task DrainAsync_WhenWorkerOutlivesBudget_ObservesLateFaultWithoutLateOutput()
{
FakeWorkerDiagnostics diagnostics = new();
List<string> output = [];
diagnostics.SetOutput(output.Add);
TaskCompletionSource worker = new(TaskCreationOptions.RunContinuationsAsynchronously);
diagnostics.Track(worker.Task);
await diagnostics.DrainAsync(TimeSpan.Zero);
Assert.Contains(output, line => line.Contains("source=teardown") && line.Contains("type=TimeoutException"));
int beforeFault = output.Count;
worker.SetException(new WorkerFrameProtocolException(WorkerFrameProtocolErrorCode.InvalidEnvelope, "secret-item"));
await diagnostics.DrainAsync(TimeSpan.FromSeconds(5));
Assert.Equal(beforeFault, output.Count);
}
/// <summary>Expected cancellation does not add fault diagnostics.</summary>
/// <returns>The asynchronous test operation.</returns>
[Fact]
public async Task DrainAsync_WhenWorkerIsCancelled_DoesNotReportFault()
{
FakeWorkerDiagnostics diagnostics = new();
List<string> output = [];
diagnostics.SetOutput(output.Add);
diagnostics.Track(Task.FromCanceled(new CancellationToken(true)));
await diagnostics.DrainAsync(TimeSpan.FromSeconds(5));
Assert.Empty(output);
}
/// <summary>An unavailable output helper cannot fault the observer or replace the primary failure.</summary>
/// <returns>The asynchronous test operation.</returns>
[Fact]
public async Task DrainAsync_WhenOutputThrows_PreservesPrimaryFailure()
{
InvalidOperationException primary = new("primary");
FakeWorkerDiagnostics diagnostics = new();
diagnostics.SetOutput(_ => throw new InvalidOperationException("completed output"));
Exception? failure = await Record.ExceptionAsync(async () =>
{
try
{
throw primary;
}
finally
{
diagnostics.Track(Task.FromException(new WorkerFrameProtocolException(
WorkerFrameProtocolErrorCode.InvalidEnvelope, "secret-item")));
await diagnostics.DrainAsync(TimeSpan.FromSeconds(5));
}
});
Assert.Same(primary, failure);
}
}
@@ -0,0 +1,98 @@
using System.IO.Pipelines;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers;
/// <summary>Protects fake worker framing while the emission gate wakes its loop.</summary>
public sealed class GatedWorkerEnvelopeReaderTests
{
/// <summary>A gate wakeup must preserve a partially consumed frame and the following frame.</summary>
/// <returns>The asynchronous test operation.</returns>
/// <param name="watchGate">Whether the emission gate is watched (false before Advise).</param>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ReadOrGateAsync_WhenGateOpensDuringSegmentedFrame_PreservesBothFrames(bool watchGate)
{
WorkerFrameProtocolOptions options = new("segmented-session");
WorkerEnvelope first = new()
{
SessionId = "segmented-session",
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
Sequence = 1,
WorkerShutdown = new WorkerShutdown(),
};
WorkerEnvelope second = first.Clone();
second.Sequence = 2;
await using MemoryStream encoded = new();
WorkerFrameWriter writer = new(encoded, options);
await writer.WriteAsync(first);
await writer.WriteAsync(second);
byte[] bytes = encoded.ToArray();
Pipe pipe = new();
await using Stream input = pipe.Reader.AsStream();
WorkerFrameReader reader = new(input, options);
using SemaphoreSlim gate = new(0);
using CancellationTokenSource lifetime = new(TimeSpan.FromSeconds(5));
await using GatedWorkerEnvelopeReader selector = new(token => reader.ReadAsync(token).AsTask(), gate, lifetime.Token);
// The length and first payload byte are consumed before the remaining bytes exist.
await pipe.Writer.WriteAsync(bytes.AsMemory(0, 5));
Task<WorkerEnvelope?> pending = selector.ReadOrGateAsync(watchGate);
Assert.False(pending.IsCompleted);
gate.Release();
if (watchGate)
{
Assert.Null(await pending.WaitAsync(TimeSpan.FromSeconds(5)));
}
else
{
// Explicit deadline probe: pre-Advise framing must remain pending beyond the old
// 50ms cancellation budget, even with the emission gate already open.
Task oldReadBudget = Task.Delay(TimeSpan.FromMilliseconds(100), lifetime.Token);
Assert.Same(oldReadBudget, await Task.WhenAny(pending, oldReadBudget));
await oldReadBudget;
}
await pipe.Writer.WriteAsync(bytes.AsMemory(5));
await pipe.Writer.CompleteAsync();
Assert.Equal(first, watchGate ? await selector.ReadOrGateAsync() : await pending.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Equal(second, await selector.ReadOrGateAsync(watchGate));
}
/// <summary>Disposal or lifetime cancellation drains the retained read and gate wait.</summary>
/// <param name="cancelLifetime">Whether cancellation precedes disposal.</param>
/// <returns>The asynchronous test operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task DisposeAsync_WithIncompleteFrame_CancelsRetainedRead(bool cancelLifetime)
{
Pipe pipe = new();
await using Stream input = pipe.Reader.AsStream();
WorkerFrameReader reader = new(input, new WorkerFrameProtocolOptions("cancel-session"));
using SemaphoreSlim gate = new(0);
using CancellationTokenSource lifetime = new();
Task<WorkerEnvelope>? frameRead = null;
GatedWorkerEnvelopeReader selector = new(
token => frameRead = reader.ReadAsync(token).AsTask(), gate, lifetime.Token);
Task<WorkerEnvelope?> pending = selector.ReadOrGateAsync();
gate.Release();
Assert.Null(await pending.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.NotNull(frameRead);
Assert.False(frameRead.IsCompleted);
if (cancelLifetime)
{
await lifetime.CancelAsync();
}
await selector.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => frameRead);
await pipe.Writer.CompleteAsync();
}
}
@@ -22,6 +22,7 @@ public sealed class WorkerProcessLauncherTests
WorkerLaunchEnvironment.EventQueueCapacity, WorkerLaunchEnvironment.EventQueueCapacity,
WorkerLaunchEnvironment.AlarmPollIntervalMilliseconds, WorkerLaunchEnvironment.AlarmPollIntervalMilliseconds,
WorkerLaunchEnvironment.MaxAlarmsPerFetch, WorkerLaunchEnvironment.MaxAlarmsPerFetch,
WorkerLaunchEnvironment.AlarmAckClientName,
]; ];
/// <summary>Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable.</summary> /// <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", "1024",
processFactory.LastStartInfo.Environment[ processFactory.LastStartInfo.Environment[
WorkerLaunchEnvironment.MaxAlarmsPerFetch]); WorkerLaunchEnvironment.MaxAlarmsPerFetch]);
Assert.Equal(
"MxGateway",
processFactory.LastStartInfo.Environment[
WorkerLaunchEnvironment.AlarmAckClientName]);
Assert.All( Assert.All(
RequiredLaunchEnvironmentVariables, RequiredLaunchEnvironmentVariables,
variable => Assert.True( variable => Assert.True(
@@ -86,6 +91,28 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(0, metrics.GetSnapshot().WorkersRunning); 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> /// <summary>
/// Verifies that a configured <see cref="WorkerOptions.EventQueueCapacity"/> — not the shipped /// 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. /// 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 startupTimeoutSeconds = 30,
int startupProbeRetryAttempts = 3, int startupProbeRetryAttempts = 3,
int startupProbeRetryDelayMilliseconds = 250, int startupProbeRetryDelayMilliseconds = 250,
int eventQueueCapacity = 10000) int eventQueueCapacity = 10000,
string ackClientName = "MxGateway")
{ {
GatewayOptions options = new() GatewayOptions options = new()
{ {
Alarms = new AlarmsOptions { AckClientName = ackClientName },
Worker = new WorkerOptions Worker = new WorkerOptions
{ {
ExecutablePath = executablePath, ExecutablePath = executablePath,
@@ -0,0 +1,135 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
/// <summary>Exposes test-owned worker tasks and safe diagnostic context to fixture cleanup.</summary>
internal interface IDiagnosticFakeWorker
{
/// <summary>Gets the diagnostic collector for this launcher's workers.</summary>
FakeWorkerDiagnostics Diagnostics { get; }
}
/// <summary>Observes fake worker faults without replacing the test's primary exception.</summary>
internal sealed class FakeWorkerDiagnostics : ILoggerFactory, ILogger
{
private readonly object _sync = new();
private readonly List<Task> _observations = [];
private Action<string>? _output;
private int _remaining = 16;
private bool _closed;
private string _phase = "startup";
private MxCommandKind? _command;
private ulong? _sequence;
/// <summary>Attaches the fixture's output sink before any workers are launched.</summary>
/// <param name="output">Destination for safe, bounded metadata.</param>
public void SetOutput(Action<string>? output) => _output = output;
/// <summary>Records only structural context, never envelopes or command arguments.</summary>
/// <param name="envelope">Incoming envelope whose safe metadata is retained.</param>
public void Received(WorkerEnvelope envelope)
{
lock (_sync)
{
_phase = envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerCommand ? "command" : "shutdown";
_command = envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerCommand
? envelope.WorkerCommand.Command.Kind : null;
_sequence = envelope.Sequence;
}
}
/// <summary>Retains an observation that consumes faults even if bounded teardown times out.</summary>
/// <param name="worker">Background task launched by the fake worker.</param>
/// <param name="source">Whether the task belongs to a fake worker or fixture teardown.</param>
public void Track(Task worker, string source = "fake-worker")
{
Task observation = worker.ContinueWith(completed =>
{
// Reading Exception observes every fault; retain no unobserved continuation fault.
if (completed.Exception is { } faults)
{
foreach (Exception fault in faults.Flatten().InnerExceptions)
{
Report(source, fault);
}
}
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
_observations.Add(observation);
}
/// <summary>Drains workers under the fixture's existing time limit and closes output.</summary>
/// <param name="timeout">Existing test timeout; never extended for diagnostics.</param>
/// <returns>The bounded cleanup operation.</returns>
public async Task DrainAsync(TimeSpan timeout)
{
try
{
await Task.WhenAll(_observations).WaitAsync(timeout).ConfigureAwait(false);
}
catch (TimeoutException exception)
{
Report("teardown", exception);
}
finally
{
Dispose();
}
}
private void Report(string source, Exception exception)
{
lock (_sync)
{
if (_closed || _remaining-- <= 0)
{
return;
}
string code = exception is WorkerFrameProtocolException protocol ? protocol.ErrorCode.ToString() : "none";
string line = $"source={source} type={exception.GetType().Name} code={code} phase={_phase} command={_command} sequence={_sequence}";
try
{
_output?.Invoke(line);
}
catch (Exception)
{
// A completed output helper must never mask the primary test failure.
}
}
}
/// <inheritdoc />
public ILogger CreateLogger(string categoryName) => this;
/// <inheritdoc />
public void AddProvider(ILoggerProvider provider) => throw new NotSupportedException();
/// <inheritdoc />
public void Dispose()
{
lock (_sync)
{
_closed = true;
_output = null;
}
}
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning;
/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
// Never invoke formatter: state and exception messages may contain item names/secrets.
if (IsEnabled(logLevel) && exception is not null)
{
Report("gateway", exception);
}
}
}
@@ -0,0 +1,69 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
/// <summary>Coordinates a fake worker's incoming frames and event emission gate.</summary>
internal sealed class GatedWorkerEnvelopeReader(
Func<CancellationToken, Task<WorkerEnvelope>> read,
SemaphoreSlim gate,
CancellationToken cancellationToken) : IAsyncDisposable
{
private readonly CancellationTokenSource _lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
private Task<WorkerEnvelope>? _pendingRead;
private Task? _pendingGate;
/// <summary>Reads an envelope, or returns null when emission should be checked.</summary>
/// <param name="watchGate">Whether the worker is currently subscribed and emitting.</param>
/// <returns>The next complete envelope, or null for an emission signal.</returns>
public async Task<WorkerEnvelope?> ReadOrGateAsync(bool watchGate = true)
{
// A gate wakeup must never cancel or replace a partially consumed frame.
_pendingRead ??= read(_lifetime.Token);
if (watchGate)
{
_pendingGate ??= gate.WaitAsync(_lifetime.Token);
await Task.WhenAny(_pendingRead, _pendingGate).ConfigureAwait(false);
if (!_pendingRead.IsCompleted)
{
await _pendingGate.ConfigureAwait(false);
_pendingGate = null;
return null;
}
}
WorkerEnvelope envelope = await _pendingRead.ConfigureAwait(false);
_pendingRead = null;
return envelope;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _lifetime.CancelAsync().ConfigureAwait(false);
try
{
if (_pendingRead is not null)
{
await _pendingRead.ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
}
finally
{
try
{
if (_pendingGate is not null)
{
await _pendingGate.ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
}
_lifetime.Dispose();
}
}
}
@@ -0,0 +1,534 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
public sealed class AlarmAckWriterTests
{
private static AlarmAckOperator Alice() =>
new AlarmAckOperator { UserName = "alice", Domain = "CORP", Password = "pw-secret" };
private static RepeatedField<MxStatusProxy> Rows(MxStatusCategory category, int detail) =>
new RepeatedField<MxStatusProxy> { new MxStatusProxy { Success = category == MxStatusCategory.Ok ? 1 : 0, Category = category, Detail = detail } };
[Fact]
public void Acknowledge_AuthenticatesThenWritesCommentWithOperatorUserId()
{
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");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal(0, result.NativeStatus);
Assert.Equal(new[] { "Register:MxGateway", "AuthenticateUser:CORP\\alice", "AddItem:T.A.AckMsg", "Advise", "Write:checked:5" }, server.Operations);
Assert.Equal("pw-secret", server.LastPassword);
}
[Fact]
public void Acknowledge_WhenWriteRejected_ReturnsDetailAndStatuses()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 1008));
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
Assert.Equal(1008, result.NativeStatus);
Assert.Single(result.Statuses);
}
[Fact]
public void Acknowledge_WhenNoCompletion_ReturnsUnconfirmed()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.Unconfirmed, result.Outcome);
Assert.Equal(0, result.NativeStatus);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Acknowledge_WhenAuthenticationFails_DoesNotWriteOrLeakPassword(bool throwComException)
{
RecordingAckServer server = new RecordingAckServer
{
AuthenticateResult = 0,
AuthenticateException = throwComException ? new COMException("bad credentials", unchecked((int)0x80070005)) : null,
};
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome);
Assert.NotEqual(0, result.NativeStatus);
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
Assert.DoesNotContain(server.Operations, op => op.Contains("pw-secret"));
}
[Fact]
public void Acknowledge_Twice_ReusesItemHandle()
{
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(1, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
Assert.Equal(1, server.Operations.Count(op => op == "Advise"));
}
[Fact]
public void Acknowledge_WhenAdviseThrows_RejectsCleansUpAndReAddsNextTime()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, AdviseException = new COMException("x", unchecked((int)0x80004005)) };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
AlarmAckResult first = writer.Acknowledge("T.A.AckMsg", "one", Alice());
server.AdviseException = null;
writer.Acknowledge("T.A.AckMsg", "two", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, first.Outcome);
Assert.Equal(unchecked((int)0x80004005), first.NativeStatus);
Assert.DoesNotContain("pw-secret", first.Diagnostic, StringComparison.Ordinal);
Assert.Contains("RemoveItem", server.Operations);
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
}
[Fact]
public void Acknowledge_WhenCachedWriteThrows_RejectsAndReAddsNextTime()
{
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());
server.WriteException = new COMException("x", unchecked((int)0x80004005));
AlarmAckResult failed = writer.Acknowledge("T.A.AckMsg", "two", Alice());
server.WriteException = null;
writer.Acknowledge("T.A.AckMsg", "three", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, failed.Outcome);
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
}
[Fact]
public void Acknowledge_WhenWriteDisconnected_TearsDownServer()
{
// The test seam has no COM factory, so re-registration itself is not
// exercised here; this verifies the teardown that makes it possible.
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new COMException("x", 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("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()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 0));
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
Assert.Equal(-1, result.NativeStatus);
}
[Fact]
public void AlarmAckOperator_ToString_OmitsPassword()
{
Assert.DoesNotContain("pw-secret", Alice().ToString(), StringComparison.Ordinal);
}
}
internal sealed class RecordingAckServer : IMxAccessServer
{
private int nextItemHandle = 100;
public List<string> Operations { get; } = new();
public int AuthenticateResult { get; set; }
public Exception? AuthenticateException { get; set; }
public Exception? AdviseException { get; set; }
public Exception? WriteException { get; set; }
public string? LastPassword { get; private set; }
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);
return 7;
}
public void Unregister(int serverHandle) => Operations.Add("Unregister");
public int AddItem(int serverHandle, string itemDefinition)
{
Operations.Add("AddItem:" + itemDefinition);
return nextItemHandle++;
}
public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition);
public void RemoveItem(int serverHandle, int itemHandle)
{
Operations.Add("RemoveItem");
RemovedItemHandles.Add(itemHandle);
}
public void Advise(int serverHandle, int itemHandle)
{
Operations.Add("Advise");
if (AdviseException is not null)
{
throw AdviseException;
}
}
public void UnAdvise(int serverHandle, int itemHandle) => Operations.Add("UnAdvise");
public void AdviseSupervisory(int serverHandle, int itemHandle)
{
}
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;
}
OnWrite?.Invoke(serverHandle, itemHandle);
}
public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId)
{
}
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
{
}
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp)
{
}
public object Suspend(int serverHandle, int itemHandle) => new object();
public object Activate(int serverHandle, int itemHandle) => new object();
public int AuthenticateUser(int serverHandle, string verifyUser, string verifyUserPassword)
{
Operations.Add("AuthenticateUser:" + verifyUser);
LastPassword = verifyUserPassword;
if (AuthenticateException is not null)
{
throw AuthenticateException;
}
return AuthenticateResult;
}
public int ArchestrAUserToId(int serverHandle, string userIdGuid) => 0;
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition);
public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds)
{
}
}
@@ -116,7 +116,7 @@ public sealed class AlarmCommandExecutorTests
[Fact] [Fact]
public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload() public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload()
{ {
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 }; FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler); MxAccessCommandExecutor executor = NewExecutor(handler);
Guid g = Guid.NewGuid(); Guid g = Guid.NewGuid();
@@ -143,7 +143,10 @@ public sealed class AlarmCommandExecutorTests
Assert.NotNull(reply.AcknowledgeAlarm); Assert.NotNull(reply.AcknowledgeAlarm);
Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus); Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus);
Assert.Equal(g, handler.LastAckGuid); Assert.Equal(g, handler.LastAckGuid);
Assert.Equal("alice", handler.LastAckOperatorName); Assert.Equal("ack", handler.LastComment);
Assert.Equal("alice", handler.LastOperator!.UserName);
Assert.Equal("CORP", handler.LastOperator.Domain);
Assert.Equal(string.Empty, handler.LastOperator.Password);
} }
/// <summary>Verifies that invalid alarm GUID returns invalid request.</summary> /// <summary>Verifies that invalid alarm GUID returns invalid request.</summary>
@@ -168,11 +171,14 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
} }
/// <summary>Verifies that nonzero native status carries a diagnostic message.</summary> /// <summary>Verifies that a rejected write carries its native status and diagnostic.</summary>
[Fact] [Fact]
public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic() public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic()
{ {
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = -123 }; FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(AlarmAckOutcome.WriteRejected, -123, "AckMsg write rejected: detail -123."),
};
MxAccessCommandExecutor executor = NewExecutor(handler); MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand( StaCommand command = new StaCommand(
@@ -188,7 +194,9 @@ public sealed class AlarmCommandExecutorTests
MxCommandReply reply = executor.Execute(command); MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Equal(-123, reply.Hresult); Assert.Equal(-123, reply.Hresult);
Assert.Equal(-123, reply.AcknowledgeAlarm.NativeStatus);
Assert.Contains("-123", reply.DiagnosticMessage); Assert.Contains("-123", reply.DiagnosticMessage);
} }
@@ -196,7 +204,7 @@ public sealed class AlarmCommandExecutorTests
[Fact] [Fact]
public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler() public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler()
{ {
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 }; FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler); MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand( StaCommand command = new StaCommand(
@@ -223,7 +231,133 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name); Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name);
Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider); Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider);
Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group); Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group);
Assert.Equal("alice", handler.LastAckOperatorName); Assert.Equal("ack", handler.LastComment);
Assert.Equal("alice", handler.LastOperator!.UserName);
}
/// <summary>
/// Every ack outcome maps to its protocol status, carries the native
/// status, and never echoes the operator password.
/// </summary>
/// <param name="outcome">The writer outcome.</param>
/// <param name="expected">The expected protocol status.</param>
[Theory]
[InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
[InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
public void AcknowledgeAlarm_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
{
int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = Guid.NewGuid().ToString(),
Comment = "checked",
OperatorUser = "alice",
OperatorDomain = "CORP",
OperatorPassword = "pw-secret",
},
});
AssertAckReply(reply, handler, expected, native);
}
/// <summary>The by-name ack maps outcomes identically and never echoes the operator password.</summary>
/// <param name="outcome">The writer outcome.</param>
/// <param name="expected">The expected protocol status.</param>
[Theory]
[InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
[InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
[InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
[InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
public void AcknowledgeAlarmByName_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
{
int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = "TestMachine_001.TestAlarm001",
ProviderName = "Galaxy",
GroupName = "TestArea",
Comment = "checked",
OperatorUser = "alice",
OperatorDomain = "CORP",
OperatorPassword = "pw-secret",
},
});
AssertAckReply(reply, handler, expected, native);
}
/// <summary>The writer's OnWriteComplete statuses are copied onto the reply.</summary>
[Fact]
public void AcknowledgeAlarm_CopiesWriteStatusesIntoReply()
{
MxStatusProxy status = new MxStatusProxy { Category = MxStatusCategory.Ok, Success = 1 };
FakeAlarmHandler handler = new FakeAlarmHandler
{
AckResult = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, new[] { status }),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand { AlarmGuid = Guid.NewGuid().ToString(), OperatorUser = "alice" },
});
Assert.Equal(status, Assert.Single(reply.Statuses));
}
/// <summary>
/// An exception escaping the ack path (e.g. the writer's MXAccess
/// Register failing) maps to MxaccessFailure with only the exception type
/// and HRESULT — never the exception text, which is not under our control.
/// </summary>
[Fact]
public void AcknowledgeAlarmByName_WhenHandlerThrows_ReportsTypeAndHresultOnly()
{
FakeAlarmHandler handler = new FakeAlarmHandler
{
AcknowledgeException = new System.Runtime.InteropServices.COMException("echo pw-secret", unchecked((int)0x80004005)),
};
MxCommandReply reply = Execute(handler, new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = "TestMachine_001.TestAlarm001",
ProviderName = "Galaxy",
OperatorUser = "alice",
OperatorPassword = "pw-secret",
},
});
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Equal(unchecked((int)0x80004005), reply.Hresult);
Assert.Contains("COMException", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.Contains("0x80004005", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message, StringComparison.Ordinal);
} }
/// <summary>Verifies that empty alarm name returns invalid request.</summary> /// <summary>Verifies that empty alarm name returns invalid request.</summary>
@@ -393,7 +527,10 @@ public sealed class AlarmCommandExecutorTests
[Fact] [Fact]
public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure() public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure()
{ {
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeThrow = true }; FakeAlarmHandler handler = new FakeAlarmHandler
{
AcknowledgeException = new InvalidOperationException("simulated alarm-handler failure"),
};
MxAccessCommandExecutor executor = NewExecutor(handler); MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand( StaCommand command = new StaCommand(
@@ -410,7 +547,25 @@ public sealed class AlarmCommandExecutorTests
MxCommandReply reply = executor.Execute(command); MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Contains("simulated", reply.DiagnosticMessage); Assert.Contains("InvalidOperationException", reply.DiagnosticMessage, StringComparison.Ordinal);
Assert.DoesNotContain("simulated", reply.DiagnosticMessage, StringComparison.Ordinal);
}
private static MxCommandReply Execute(IAlarmCommandHandler handler, MxCommand command) =>
NewExecutor(handler).Execute(new StaCommand(SessionId, CorrelationId, command));
private static void AssertAckReply(MxCommandReply reply, FakeAlarmHandler handler, ProtocolStatusCode expected, int native)
{
Assert.Equal(expected, reply.ProtocolStatus.Code);
Assert.Equal(native, reply.Hresult);
Assert.Equal(native, reply.AcknowledgeAlarm.NativeStatus);
Assert.Equal("diagnostic for alice", reply.DiagnosticMessage);
Assert.Equal("checked", handler.LastComment);
Assert.Equal("alice", handler.LastOperator!.UserName);
Assert.Equal("CORP", handler.LastOperator.Domain);
Assert.Equal("pw-secret", handler.LastOperator.Password);
Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal);
Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal);
} }
private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler) private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler)
@@ -442,14 +597,17 @@ public sealed class AlarmCommandExecutorTests
/// <summary>Gets the last acknowledge alarm GUID.</summary> /// <summary>Gets the last acknowledge alarm GUID.</summary>
public Guid LastAckGuid { get; private set; } public Guid LastAckGuid { get; private set; }
/// <summary>Gets the last acknowledge operator name.</summary> /// <summary>Gets the last acknowledge comment.</summary>
public string? LastAckOperatorName { get; private set; } public string? LastComment { get; private set; }
/// <summary>Gets or sets the value returned by acknowledge.</summary> /// <summary>Gets the last acknowledge operator.</summary>
public int AcknowledgeReturn { get; set; } public AlarmAckOperator? LastOperator { get; private set; }
/// <summary>Gets or sets a value indicating whether acknowledge throws.</summary> /// <summary>Gets or sets the result returned by both acknowledge methods.</summary>
public bool AcknowledgeThrow { get; set; } public AlarmAckResult AckResult { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
/// <summary>Gets or sets an exception both acknowledge methods throw.</summary>
public Exception? AcknowledgeException { get; set; }
/// <summary>Gets or sets the query result snapshots.</summary> /// <summary>Gets or sets the query result snapshots.</summary>
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } = public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
@@ -484,28 +642,32 @@ public sealed class AlarmCommandExecutorTests
} }
/// <inheritdoc /> /// <inheritdoc />
public int Acknowledge( public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
Guid alarmGuid, string comment, string operatorUser,
string operatorNode, string operatorDomain, string operatorFullName)
{ {
LastAckGuid = alarmGuid; LastAckGuid = alarmGuid;
LastAckOperatorName = operatorUser; LastComment = comment;
if (AcknowledgeThrow) LastOperator = ackOperator;
if (AcknowledgeException is not null)
{ {
throw new InvalidOperationException("simulated alarm-handler failure"); throw AcknowledgeException;
} }
return AcknowledgeReturn;
return AckResult;
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckResult AcknowledgeByName(
string alarmName, string providerName, string groupName, string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator)
string comment, string operatorUser, string operatorNode,
string operatorDomain, string operatorFullName)
{ {
LastAckByNameTuple = (alarmName, providerName, groupName); LastAckByNameTuple = (alarmName, providerName, groupName);
LastAckOperatorName = operatorUser; LastComment = comment;
return AcknowledgeReturn; LastOperator = ackOperator;
if (AcknowledgeException is not null)
{
throw AcknowledgeException;
}
return AckResult;
} }
/// <summary>Gets the last acknowledge by name tuple.</summary> /// <summary>Gets the last acknowledge by name tuple.</summary>
@@ -96,22 +96,100 @@ public sealed class AlarmCommandHandlerTests
Assert.False(handler.IsSubscribed); Assert.False(handler.IsSubscribed);
} }
/// <summary>Verifies that acknowledge forwards to consumer with full operator identity when subscribed.</summary> /// <summary>Verifies that acknowledge resolves via the consumer and writes through the injected writer as the operator.</summary>
[Fact] [Fact]
public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity() public void Acknowledge_WhenSubscribed_ResolvesTargetAndWritesAsOperator()
{ {
FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 }; FakeConsumer consumer = new FakeConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
AlarmCommandHandler handler = new AlarmCommandHandler( RecordingAckWriter writer = new RecordingAckWriter();
new MxAccessEventQueue(), AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer);
() => consumer);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Guid g = Guid.NewGuid(); Guid g = Guid.NewGuid();
int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F"); AlarmAckOperator op = new AlarmAckOperator { UserName = "u", Domain = "d", Password = "p" };
AlarmAckResult result = handler.Acknowledge(g, "c", op);
Assert.Equal(0, rc); Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal(g, consumer.LastAckGuid); Assert.Equal(g, consumer.LastResolvedGuid);
Assert.Equal("u", consumer.LastAckOperatorName); Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
Assert.Equal("c", writer.LastComment);
Assert.Same(op, writer.LastOperator);
}
/// <summary>Verifies acknowledge-by-name forwards the tuple and a null comment becomes empty.</summary>
[Fact]
public void AcknowledgeByName_WhenSubscribed_ForwardsTupleAndNormalizesComment()
{
FakeConsumer consumer = new FakeConsumer();
RecordingAckWriter writer = new RecordingAckWriter();
AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
handler.AcknowledgeByName("Tag1", "Galaxy", "Area", null!, new AlarmAckOperator { UserName = "u" });
Assert.Equal(("Tag1", "Galaxy", "Area"), consumer.LastResolvedName);
Assert.Equal(string.Empty, writer.LastComment);
}
/// <summary>Verifies a null operator is rejected.</summary>
[Fact]
public void Acknowledge_NullOperator_Throws()
{
AlarmCommandHandler handler = NewHandlerWithWriter(() => new FakeConsumer(), new RecordingAckWriter());
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Throws<ArgumentNullException>(() => handler.Acknowledge(Guid.NewGuid(), "c", null!));
}
/// <summary>
/// The ack writer is created once, on first acknowledgement, survives an
/// unsubscribe/resubscribe cycle, and is disposed with the handler.
/// </summary>
[Fact]
public void AckWriter_CreatedLazilyOnce_AndDisposedWithHandler()
{
RecordingAckWriter writer = new RecordingAckWriter();
int created = 0;
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => new FakeConsumer(),
threadAffinityCheck: null,
comFactory: null,
standbyFactory: null,
ackWriterFactory: () =>
{
created++;
return writer;
});
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Equal(0, created);
handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator());
handler.Unsubscribe();
Assert.False(writer.Disposed);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
handler.AcknowledgeByName("a", "Galaxy", "g", "c", new AlarmAckOperator());
Assert.Equal(1, created);
handler.Dispose();
Assert.True(writer.Disposed);
}
/// <summary>
/// Without a COM factory or writer factory, subscribing still works and
/// the acknowledgement fails with a diagnosable InvalidOperationException.
/// </summary>
[Fact]
public void Acknowledge_WithoutComFactoryOrWriterFactory_ThrowsInvalidOperation()
{
AlarmCommandHandler handler = new AlarmCommandHandler(new MxAccessEventQueue(), () => new FakeConsumer());
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator()));
Assert.Contains("IMxAccessComObjectFactory", ex.Message);
} }
/// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary> /// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary>
@@ -123,7 +201,7 @@ public sealed class AlarmCommandHandlerTests
() => new FakeConsumer()); () => new FakeConsumer());
Assert.Throws<InvalidOperationException>( Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); () => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
} }
/// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary> /// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary>
@@ -266,7 +344,10 @@ public sealed class AlarmCommandHandlerTests
AlarmCommandHandler handler = new AlarmCommandHandler( AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(), new MxAccessEventQueue(),
() => consumer, () => consumer,
() => guardInvocations++); () => guardInvocations++,
comFactory: null,
standbyFactory: null,
ackWriterFactory: () => new RecordingAckWriter());
// Subscribe is the first call — guard must run before the consumer // Subscribe is the first call — guard must run before the consumer
// factory is invoked. We tally invocation counts after each call so // factory is invoked. We tally invocation counts after each call so
@@ -275,10 +356,10 @@ public sealed class AlarmCommandHandlerTests
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Equal(1, guardInvocations); Assert.Equal(1, guardInvocations);
handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F"); handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "u" });
Assert.Equal(2, guardInvocations); Assert.Equal(2, guardInvocations);
handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F"); handler.AcknowledgeByName("a", "p", "g", "c", new AlarmAckOperator { UserName = "u" });
Assert.Equal(3, guardInvocations); Assert.Equal(3, guardInvocations);
_ = handler.QueryActive(null, out _); _ = handler.QueryActive(null, out _);
@@ -323,9 +404,9 @@ public sealed class AlarmCommandHandlerTests
// assert each independently with a fresh handler. Below we reuse // assert each independently with a fresh handler. Below we reuse
// the same throwing handler for the not-subscribed-yet entries: // the same throwing handler for the not-subscribed-yet entries:
Assert.Throws<InvalidOperationException>( Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); () => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
Assert.Throws<InvalidOperationException>( Assert.Throws<InvalidOperationException>(
() => handler.AcknowledgeByName("", "", "", "", "", "", "", "")); () => handler.AcknowledgeByName("", "", "", "", new AlarmAckOperator()));
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _)); Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _));
Assert.Throws<InvalidOperationException>(() => handler.PollOnce()); Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
Assert.Throws<InvalidOperationException>( Assert.Throws<InvalidOperationException>(
@@ -422,16 +503,17 @@ public sealed class AlarmCommandHandlerTests
[Fact] [Fact]
public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby() public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby()
{ {
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 }; FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
FakeConsumer initialStandby = new FakeConsumer { AcknowledgeReturn = 22 }; FakeConsumer initialStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 }; FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0; int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler( AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(), new MxAccessEventQueue(),
() => primary, () => primary,
threadAffinityCheck: null, threadAffinityCheck: null,
comFactory: null, comFactory: null,
standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement); standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement,
ackWriterFactory: () => new RecordingAckWriter());
handler.Subscribe(new SubscribeAlarmsCommand handler.Subscribe(new SubscribeAlarmsCommand
{ {
@@ -443,7 +525,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(2, buildCount); Assert.Equal(2, buildCount);
Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription); Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription);
Assert.True(initialStandby.Disposed); Assert.True(initialStandby.Disposed);
Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); Assert.Equal("11.AckMsg", AckItemOf(handler));
} }
/// <summary>A replacement is rejected when no fallback-capable subscription is active.</summary> /// <summary>A replacement is rejected when no fallback-capable subscription is active.</summary>
@@ -471,16 +553,17 @@ public sealed class AlarmCommandHandlerTests
[Fact] [Fact]
public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode() public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode()
{ {
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 }; FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 }; FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 33 }; FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0; int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler( AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(), new MxAccessEventQueue(),
() => primary, () => primary,
null, null,
null, null,
_ => ++buildCount == 1 ? oldStandby : failedReplacement); _ => ++buildCount == 1 ? oldStandby : failedReplacement,
() => new RecordingAckWriter());
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
{ {
SubscriptionExpression = @"\\HOST\Galaxy!Area", SubscriptionExpression = @"\\HOST\Galaxy!Area",
@@ -494,23 +577,24 @@ public sealed class AlarmCommandHandlerTests
Assert.False(oldStandby.Disposed); Assert.False(oldStandby.Disposed);
Assert.True(failedReplacement.Disposed); Assert.True(failedReplacement.Disposed);
Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); Assert.Equal("11.AckMsg", AckItemOf(handler));
} }
/// <summary>A successful replacement while failed over keeps subtag mode active and delegates to the new standby.</summary> /// <summary>A successful replacement while failed over keeps subtag mode active and delegates to the new standby.</summary>
[Fact] [Fact]
public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement() public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement()
{ {
FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 11 }; FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("11.AckMsg") };
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 }; FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 }; FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0; int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler( AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(), new MxAccessEventQueue(),
() => primary, () => primary,
null, null,
null, null,
_ => ++buildCount == 1 ? oldStandby : replacement); _ => ++buildCount == 1 ? oldStandby : replacement,
() => new RecordingAckWriter());
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
{ {
SubscriptionExpression = @"\\HOST\Galaxy!Area", SubscriptionExpression = @"\\HOST\Galaxy!Area",
@@ -523,7 +607,7 @@ public sealed class AlarmCommandHandlerTests
handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") }); handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") });
Assert.True(oldStandby.Disposed); Assert.True(oldStandby.Disposed);
Assert.Equal(33, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); Assert.Equal("33.AckMsg", AckItemOf(handler));
} }
/// <summary> /// <summary>
@@ -567,6 +651,19 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime()); Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime());
} }
private static AlarmCommandHandler NewHandlerWithWriter(Func<IMxAccessAlarmConsumer> consumerFactory, IAlarmAckWriter writer) =>
new AlarmCommandHandler(
new MxAccessEventQueue(),
consumerFactory,
threadAffinityCheck: null,
comFactory: null,
standbyFactory: null,
ackWriterFactory: () => writer);
/// <summary>Acks through the handler and returns the item the active consumer resolved (the fake writer echoes it as the diagnostic).</summary>
private static string AckItemOf(AlarmCommandHandler handler) =>
handler.Acknowledge(Guid.Empty, string.Empty, new AlarmAckOperator()).Diagnostic;
private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag) private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag)
{ {
return new MxAlarmSnapshotRecord return new MxAlarmSnapshotRecord
@@ -598,12 +695,12 @@ public sealed class AlarmCommandHandlerTests
/// <summary>Gets the last subscription request.</summary> /// <summary>Gets the last subscription request.</summary>
public string? LastSubscription { get; private set; } public string? LastSubscription { get; private set; }
/// <summary>Gets the last acknowledged alarm GUID.</summary> /// <summary>Gets or sets the ack target both resolve methods return.</summary>
public Guid LastAckGuid { get; private set; } public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
/// <summary>Gets the last acknowledged operator name.</summary> /// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
public string? LastAckOperatorName { get; private set; } public Guid LastResolvedGuid { get; private set; }
/// <summary>Gets or sets the return value for acknowledge operations.</summary> /// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
public int AcknowledgeReturn { get; set; } public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
/// <summary>Gets or sets the snapshot result to return.</summary> /// <summary>Gets or sets the snapshot result to return.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } = public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>(); Array.Empty<MxAlarmSnapshotRecord>();
@@ -623,29 +720,19 @@ public sealed class AlarmCommandHandlerTests
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid( public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
Guid alarmGuid, string ackComment, string ackOperatorName,
string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
{ {
LastAckGuid = alarmGuid; LastResolvedGuid = alarmGuid;
LastAckOperatorName = ackOperatorName; return AckTarget;
return AcknowledgeReturn;
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
string ackOperatorDomain, string ackOperatorFullName)
{ {
LastAckByNameTuple = (alarmName, providerName, groupName); LastResolvedName = (alarmName, providerName, groupName);
LastAckOperatorName = ackOperatorName; return AckTarget;
return AcknowledgeReturn;
} }
/// <summary>Gets the last acknowledge-by-name parameters.</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary> /// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
public bool SnapshotTruncated { get; set; } public bool SnapshotTruncated { get; set; }
@@ -671,4 +758,22 @@ public sealed class AlarmCommandHandlerTests
Disposed = true; Disposed = true;
} }
} }
private sealed class RecordingAckWriter : IAlarmAckWriter
{
public string? LastItemAddress { get; private set; }
public string? LastComment { get; private set; }
public AlarmAckOperator? LastOperator { get; private set; }
public bool Disposed { get; private set; }
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
{
LastItemAddress = itemAddress;
LastComment = comment;
LastOperator = ackOperator;
return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, itemAddress);
}
public void Dispose() => Disposed = true;
}
} }
@@ -17,6 +17,38 @@ public sealed class AlarmDispatcherTests
{ {
private const string SessionId = "session-001"; private const string SessionId = "session-001";
/// <summary>Verifies an Acknowledge transition is attributed to the ACK_* operator and comment.</summary>
[Fact]
public void OnTransition_WhenAcknowledged_UsesAckOperatorAndComment()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
PreviousState = MxAlarmStateKind.UnackAlm,
Record = new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "TestArea",
TagName = "TestMachine_001.TestAlarm001",
State = MxAlarmStateKind.AckAlm,
AlarmComment = "Test alarm #1",
AckOperatorName = "alice",
AckComment = "checked",
},
});
Assert.True(queue.TryDequeue(out WorkerEvent? evt));
OnAlarmTransitionEvent body = evt!.Event.OnAlarmTransition;
Assert.Equal(AlarmTransitionKind.Acknowledge, body.TransitionKind);
Assert.Equal("alice", body.OperatorUser);
Assert.Equal("checked", body.OperatorComment);
}
/// <summary>Verifies that alarm transitions land in the queue with correctly mapped fields.</summary> /// <summary>Verifies that alarm transitions land in the queue with correctly mapped fields.</summary>
[Fact] [Fact]
public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields() public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields()
@@ -24,7 +56,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer(); FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc); DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
consumer.RaiseTransition(new MxAlarmTransitionEvent consumer.RaiseTransition(new MxAlarmTransitionEvent
@@ -110,7 +142,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer(); FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent consumer.RaiseTransition(new MxAlarmTransitionEvent
{ {
@@ -145,7 +177,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer(); FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent consumer.RaiseTransition(new MxAlarmTransitionEvent
{ {
@@ -173,61 +205,140 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
new FakeAckWriter());
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription); Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
} }
/// <summary>Verifies that acknowledge calls are forwarded to the consumer with operator identity.</summary> /// <summary>Verifies the dispatcher resolves the ack item via the consumer then writes it as the operator.</summary>
[Fact] [Fact]
public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity() public void Acknowledge_ResolvesTargetThenWritesWithOperator()
{ {
FakeAlarmConsumer consumer = new FakeAlarmConsumer(); FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
consumer.AcknowledgeReturn = 0; FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
Guid guid = Guid.NewGuid(); Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge( AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" };
guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
Assert.Equal(0, rc); AlarmAckResult result = dispatcher.Acknowledge(guid, "checked", op);
Assert.Equal(guid, consumer.LastAckGuid);
Assert.Equal("Acked", consumer.LastAckComment); Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal("alice", consumer.LastAckOperatorName); Assert.Equal(guid, consumer.LastResolvedGuid);
Assert.Equal("WS01", consumer.LastAckOperatorNode); Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
Assert.Equal("CORP", consumer.LastAckOperatorDomain); Assert.Equal("checked", writer.LastComment);
Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName); Assert.Same(op, writer.LastOperator);
} }
/// <summary>Verifies that acknowledge-by-name calls are forwarded to the consumer.</summary> /// <summary>Verifies an unresolved target short-circuits to its failure without calling the writer.</summary>
[Fact] [Fact]
public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple() public void Acknowledge_WhenTargetNotFound_DoesNotWrite()
{ {
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotFound("gone") };
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" });
Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome);
Assert.Equal("gone", result.Diagnostic);
Assert.Null(writer.LastItemAddress);
}
/// <summary>Verifies acknowledge-by-name resolves with the full tuple and writes the resolved item.</summary>
[Fact]
public void AcknowledgeByName_ResolvesTupleThenWritesWithOperator()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer
{
AckTarget = AlarmAckTarget.For("TestMachine_001.TestAlarm001.AckMsg"),
};
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Domain = "CORP" };
AlarmAckResult result = dispatcher.AcknowledgeByName(
"TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "ack", op);
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal(("TestMachine_001.TestAlarm001", "Galaxy", "TestArea"), consumer.LastResolvedName);
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", writer.LastItemAddress);
Assert.Equal("ack", writer.LastComment);
Assert.Same(op, writer.LastOperator);
}
/// <summary>Verifies a NotAckable by-name target short-circuits without calling the writer.</summary>
[Fact]
public void AcknowledgeByName_WhenNotAckable_DoesNotWrite()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotAckable("intouch") };
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
AlarmAckResult result = dispatcher.AcknowledgeByName("Tag1", "InTouch", "G", "c", new AlarmAckOperator());
Assert.Equal(AlarmAckOutcome.NotAckable, result.Outcome);
Assert.Null(writer.LastItemAddress);
}
/// <summary>
/// Verifies that while the failover consumer serves the subtag standby,
/// GUID acks resolve through the standby (its synthetic GUIDs).
/// </summary>
[Fact]
public void Acknowledge_WhenFailoverInSubtagMode_ResolvesThroughStandby()
{
FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true };
FakeAlarmConsumer standby = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("Standby.AckMsg") };
FailoverAlarmConsumer failover = new FailoverAlarmConsumer(
primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3));
FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
failover,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
writer);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Assert.Equal(AlarmProviderMode.Subtag, failover.Mode);
int rc = dispatcher.AcknowledgeByName( Guid guid = Guid.NewGuid();
alarmName: "TestMachine_001.TestAlarm001", dispatcher.Acknowledge(guid, "Acked", new AlarmAckOperator { UserName = "alice" });
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "ack",
ackOperatorName: "alice",
ackOperatorNode: "WS",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
Assert.Equal(0, rc); Assert.Equal(guid, standby.LastResolvedGuid);
Assert.NotNull(consumer.LastAckByNameTuple); Assert.Equal(Guid.Empty, primary.LastResolvedGuid);
Assert.Equal("TestMachine_001.TestAlarm001", consumer.LastAckByNameTuple!.Value.Name); Assert.Equal("Standby.AckMsg", writer.LastItemAddress);
Assert.Equal("Galaxy", consumer.LastAckByNameTuple!.Value.Provider); }
Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group);
/// <summary>Verifies the dispatcher does not dispose the writer: the handler owns it.</summary>
[Fact]
public void Dispose_DoesNotDisposeAckWriter()
{
FakeAckWriter writer = new FakeAckWriter();
AlarmDispatcher dispatcher = new AlarmDispatcher(
new FakeAlarmConsumer(),
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
writer);
dispatcher.Dispose();
Assert.False(writer.Disposed);
}
/// <summary>Verifies the constructor rejects a null ack writer.</summary>
[Fact]
public void Constructor_NullAckWriter_Throws()
{
Assert.Throws<ArgumentNullException>(() => new AlarmDispatcher(
new FakeAlarmConsumer(),
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
null!));
} }
/// <summary>Verifies that consumer alarm records are mapped to proto snapshots.</summary> /// <summary>Verifies that consumer alarm records are mapped to proto snapshots.</summary>
@@ -265,7 +376,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _); IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
Assert.Equal(2, snapshots.Count); Assert.Equal(2, snapshots.Count);
@@ -321,7 +433,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _); IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
Assert.Equal(2, snapshots.Count); Assert.Equal(2, snapshots.Count);
@@ -356,7 +469,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated); IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
@@ -386,7 +500,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher( using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer, consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId); SessionId,
new FakeAckWriter());
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated); IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
@@ -416,7 +531,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer(); FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
dispatcher.Dispose(); dispatcher.Dispose();
@@ -443,20 +558,12 @@ public sealed class AlarmDispatcherTests
/// <summary>Gets the last subscription reference.</summary> /// <summary>Gets the last subscription reference.</summary>
public string? LastSubscription { get; private set; } public string? LastSubscription { get; private set; }
/// <summary>Gets the GUID from the last acknowledge call.</summary> /// <summary>Gets or sets the ack target both resolve methods return.</summary>
public Guid LastAckGuid { get; private set; } public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
/// <summary>Gets the comment from the last acknowledge call.</summary> /// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
public string? LastAckComment { get; private set; } public Guid LastResolvedGuid { get; private set; }
/// <summary>Gets the operator name from the last acknowledge call.</summary> /// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
public string? LastAckOperatorName { get; private set; } public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
/// <summary>Gets the operator node from the last acknowledge call.</summary>
public string? LastAckOperatorNode { get; private set; }
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
public string? LastAckOperatorDomain { get; private set; }
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
public string? LastAckOperatorFullName { get; private set; }
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets the result collection for snapshot operations.</summary> /// <summary>Gets or sets the result collection for snapshot operations.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } = public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>(); Array.Empty<MxAlarmSnapshotRecord>();
@@ -470,44 +577,30 @@ public sealed class AlarmDispatcherTests
AlarmTransitionEmitted?.Invoke(this, transition); AlarmTransitionEmitted?.Invoke(this, transition);
} }
/// <summary>Gets or sets a value indicating whether <see cref="Subscribe"/> throws.</summary>
public bool ThrowOnSubscribe { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public void Subscribe(string subscription) public void Subscribe(string subscription)
{ {
if (ThrowOnSubscribe) throw new InvalidOperationException("subscribe failed");
LastSubscription = subscription; LastSubscription = subscription;
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid( public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
LastAckGuid = alarmGuid; LastResolvedGuid = alarmGuid;
LastAckComment = ackComment; return AckTarget;
LastAckOperatorName = ackOperatorName;
LastAckOperatorNode = ackOperatorNode;
LastAckOperatorDomain = ackOperatorDomain;
LastAckOperatorFullName = ackOperatorFullName;
return AcknowledgeReturn;
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
string ackOperatorDomain, string ackOperatorFullName)
{ {
LastAckByNameTuple = (alarmName, providerName, groupName); LastResolvedName = (alarmName, providerName, groupName);
LastAckOperatorName = ackOperatorName; return AckTarget;
return AcknowledgeReturn;
} }
/// <summary>Gets the last acknowledge-by-name tuple (alarm name, provider, group).</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary> /// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
public bool SnapshotTruncated { get; set; } public bool SnapshotTruncated { get; set; }
@@ -533,4 +626,23 @@ public sealed class AlarmDispatcherTests
Disposed = true; Disposed = true;
} }
} }
private sealed class FakeAckWriter : IAlarmAckWriter
{
public string? LastItemAddress { get; private set; }
public string? LastComment { get; private set; }
public AlarmAckOperator? LastOperator { get; private set; }
public AlarmAckResult Result { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
public bool Disposed { get; private set; }
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
{
LastItemAddress = itemAddress;
LastComment = comment;
LastOperator = ackOperator;
return Result;
}
public void Dispose() => Disposed = true;
}
} }
@@ -72,10 +72,10 @@ public sealed class FailoverAlarmConsumerTests
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 11; public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("primary.guid.AckMsg");
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 11; public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("primary.name.AckMsg");
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated) public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
@@ -122,10 +122,10 @@ public sealed class FailoverAlarmConsumerTests
public void PollOnce() { } public void PollOnce() { }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 22; public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("standby.guid.AckMsg");
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 22; public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("standby.name.AckMsg");
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated) public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
@@ -305,9 +305,9 @@ public sealed class FailoverAlarmConsumerTests
Assert.Equal(subscribeCountAfterSetup, primary.SubscribeCount); Assert.Equal(subscribeCountAfterSetup, primary.SubscribeCount);
} }
/// <summary>Proves that acknowledgment calls delegate to whichever child (primary or standby) is currently active.</summary> /// <summary>Proves that ack-target resolution delegates to whichever child (primary or standby) is currently active.</summary>
[Fact] [Fact]
public void Acknowledge_DelegatesToActiveChild() public void ResolveAckTarget_DelegatesToActiveChild()
{ {
FlakyPrimary primary = new FlakyPrimary { ThrowOnPoll = false }; FlakyPrimary primary = new FlakyPrimary { ThrowOnPoll = false };
StubStandby standby = new StubStandby(); StubStandby standby = new StubStandby();
@@ -316,18 +316,18 @@ public sealed class FailoverAlarmConsumerTests
sut.Subscribe(@"\\HOST\Galaxy!Area"); sut.Subscribe(@"\\HOST\Galaxy!Area");
// Active = Primary → primary's sentinel value (11). // Active = Primary → primary's sentinel targets.
Assert.Equal(11, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full")); Assert.Equal("primary.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress);
Assert.Equal(11, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full")); Assert.Equal("primary.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress);
// Force a failover by failing the primary past threshold. // Force a failover by failing the primary past threshold.
primary.ThrowOnPoll = true; primary.ThrowOnPoll = true;
sut.PollOnce(); // threshold=1 → switch to Standby sut.PollOnce(); // threshold=1 → switch to Standby
Assert.Equal(AlarmProviderMode.Subtag, sut.Mode); Assert.Equal(AlarmProviderMode.Subtag, sut.Mode);
// Active = Standby → standby's sentinel value (22). // Active = Standby → standby's sentinel targets.
Assert.Equal(22, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full")); Assert.Equal("standby.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress);
Assert.Equal(22, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full")); Assert.Equal("standby.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress);
} }
/// <summary> /// <summary>
@@ -94,76 +94,28 @@ public sealed class LmxSubtagAlarmSourceTests
} }
/// <summary> /// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> adds the item when /// Verifies <see cref="LmxSubtagAlarmSource.Dispose"/> UnAdvises and
/// it was not previously advised and writes with user id 0. /// removes every advised handle and unregisters the server exactly once.
/// </summary> /// </summary>
[Fact] [Fact]
public void Write_AddsItemWhenUnknownAndWrites() public void Dispose_UnAdvisesAndRemovesAdvisedHandles_AndUnregistersOnce()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Write("Tank1.Alarm.AckComment", "acknowledged");
Assert.Contains("Tank1.Alarm.AckComment", server.AddedItems);
Assert.Single(server.Writes);
RecordingMxAccessServer.WriteRecord write = server.Writes[0];
Assert.Equal(FakeServerHandle, write.ServerHandle);
Assert.Equal("acknowledged", write.Value);
Assert.Equal(0, write.UserId);
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> reuses an existing
/// item handle (no duplicate AddItem) when the address was already
/// advised.
/// </summary>
[Fact]
public void Write_ReusesHandleForAdvisedAddress()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Advise(new[] { "Tank1.Alarm.Subtag" });
int adviseAddCount = server.AddedItems.Count;
source.Write("Tank1.Alarm.Subtag", true);
// No second AddItem for the already-advised address.
Assert.Equal(adviseAddCount, server.AddedItems.Count);
Assert.Single(server.Writes);
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Dispose"/> UnAdvises only the
/// handles that were actually advised — a write-only item (added by
/// <see cref="LmxSubtagAlarmSource.Write"/> but never advised) is removed
/// but not unadvised — and unregisters the server exactly once.
/// </summary>
[Fact]
public void Dispose_UnAdvisesOnlyAdvisedHandles_RemovesAll_AndUnregistersOnce()
{ {
var server = new RecordingMxAccessServer(); var server = new RecordingMxAccessServer();
var source = new LmxSubtagAlarmSource(server, FakeServerHandle); var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Advise(new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" }); source.Advise(new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" });
// A write-only subtag: added by Write, never advised.
source.Write("Tank1.Alarm.AckComment", "acknowledged");
int advised1 = server.LastItemHandleFor("Tank1.Alarm.Subtag"); int advised1 = server.LastItemHandleFor("Tank1.Alarm.Subtag");
int advised2 = server.LastItemHandleFor("Tank2.Alarm.Subtag"); int advised2 = server.LastItemHandleFor("Tank2.Alarm.Subtag");
int writeOnly = server.LastItemHandleFor("Tank1.Alarm.AckComment");
source.Dispose(); source.Dispose();
// Only the two advised handles are unadvised — never the write-only one.
Assert.Equal(new[] { advised1, advised2 }, server.UnAdvisedItemHandles); Assert.Equal(new[] { advised1, advised2 }, server.UnAdvisedItemHandles);
Assert.DoesNotContain(writeOnly, server.UnAdvisedItemHandles);
// Every added item (advised + write-only) is removed.
Assert.Equal( Assert.Equal(
new[] { advised1, advised2, writeOnly }.OrderBy(h => h), new[] { advised1, advised2 }.OrderBy(h => h),
server.RemovedItemHandles.OrderBy(h => h)); server.RemovedItemHandles.OrderBy(h => h));
Assert.Equal(1, server.UnregisterCount); Assert.Equal(1, server.UnregisterCount);
Assert.Empty(server.Writes);
} }
/// <summary> /// <summary>
@@ -663,15 +663,13 @@ public sealed class MxAccessStaSessionTests
} }
/// <inheritdoc /> /// <inheritdoc />
public int Acknowledge(Guid alarmGuid, string comment, string operatorUser, public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
string operatorNode, string operatorDomain, string operatorFullName) => AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
=> 0;
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName(string alarmName, string providerName, string groupName, public AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName,
string comment, string operatorUser, string operatorNode, string comment, AlarmAckOperator ackOperator)
string operatorDomain, string operatorFullName) => AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
=> 0;
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive( public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
@@ -183,6 +183,39 @@ public sealed class MxAccessWriteCompletionCacheTests
Assert.Equal(77, Assert.Single(statuses).Detail); 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) private static RepeatedField<MxStatusProxy> BuildStatuses(int detail)
{ {
return new RepeatedField<MxStatusProxy> return new RepeatedField<MxStatusProxy>
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary> /// <summary>
/// Unit tests for <see cref="SubtagAlarmConsumer"/>: prove that the subtag /// Unit tests for <see cref="SubtagAlarmConsumer"/>: prove that the subtag
/// fallback advises the observable subtags, synthesizes degraded /// fallback advises the observable subtags, synthesizes degraded
/// transitions with stable synthetic GUIDs, routes acknowledgments to the /// transitions with stable synthetic GUIDs, resolves acknowledgments to the
/// ack-comment subtag, and stamps snapshots. A <see cref="FakeSource"/> /// ack-comment subtag, and stamps snapshots. A <see cref="FakeSource"/>
/// stands in for the live MXAccess subtag source so this needs no AVEVA /// stands in for the live MXAccess subtag source so this needs no AVEVA
/// install. /// install.
@@ -40,9 +40,13 @@ public sealed class SubtagAlarmConsumerTests
return new SubtagAlarmConsumer(source, new[] { BuildTarget() }); return new SubtagAlarmConsumer(source, new[] { BuildTarget() });
} }
/// <summary>Verifies Subscribe advises the active, acked, priority, and ack-comment subtags (the ack-comment is advised so it is an active, writable MXAccess item).</summary> /// <summary>
/// Verifies Subscribe advises the observable active, acked and priority
/// subtags but not the ack-comment subtag: the ack is written by
/// <see cref="AlarmAckWriter"/>, which adds and advises it on its own server.
/// </summary>
[Fact] [Fact]
public void Subscribe_AdvisesAllSubtagsIncludingAckComment() public void Subscribe_AdvisesObservableSubtagsButNotAckComment()
{ {
FakeSource source = new FakeSource(); FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source); using SubtagAlarmConsumer consumer = BuildConsumer(source);
@@ -52,7 +56,7 @@ public sealed class SubtagAlarmConsumerTests
Assert.Contains(ActiveSubtag, source.Advised); Assert.Contains(ActiveSubtag, source.Advised);
Assert.Contains(AckedSubtag, source.Advised); Assert.Contains(AckedSubtag, source.Advised);
Assert.Contains(PrioritySubtag, source.Advised); Assert.Contains(PrioritySubtag, source.Advised);
Assert.Contains(AckCommentSubtag, source.Advised); Assert.DoesNotContain(AckCommentSubtag, source.Advised);
} }
/// <summary>Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition.</summary> /// <summary>Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition.</summary>
@@ -74,50 +78,49 @@ public sealed class SubtagAlarmConsumerTests
Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid); Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid);
} }
/// <summary>Verifies AcknowledgeByName writes the comment to the ack-comment subtag and returns success.</summary> /// <summary>Verifies ResolveAckTargetByName resolves to the target's ack-comment subtag.</summary>
[Fact] [Fact]
public void AcknowledgeByName_WritesCommentToAckCommentSubtag() public void ResolveAckTargetByName_ReturnsAckCommentSubtag()
{ {
FakeSource source = new FakeSource(); FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source); using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByName( AlarmAckTarget target = consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea");
alarmName: "Tank01.Level.HiHi",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "operator ack",
ackOperatorName: "alice",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
Assert.Equal(0, rc); Assert.True(target.Found);
Assert.NotNull(source.LastWrite); Assert.Equal(AckCommentSubtag, target.ItemAddress);
Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
Assert.Equal("operator ack", source.LastWrite!.Value.Value);
} }
/// <summary>Verifies AcknowledgeByName returns non-zero when no target matches the supplied name.</summary> /// <summary>Verifies ResolveAckTargetByName reports AlarmNotFound when no target matches the supplied name.</summary>
[Fact] [Fact]
public void AcknowledgeByName_UnknownAlarm_ReturnsNonZero() public void ResolveAckTargetByName_UnknownAlarm_IsNotFound()
{ {
FakeSource source = new FakeSource(); FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source); using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByName( AlarmAckTarget target = consumer.ResolveAckTargetByName("DoesNotExist.NoSuchAlarm", "Galaxy", "TestArea");
alarmName: "DoesNotExist.NoSuchAlarm",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "operator ack",
ackOperatorName: "alice",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
Assert.NotEqual(0, rc); Assert.False(target.Found);
Assert.Null(source.LastWrite); Assert.Equal(AlarmAckOutcome.AlarmNotFound, target.Failure);
}
/// <summary>Verifies a target with no ack-comment subtag resolves as NotAckable, by name and by GUID.</summary>
[Fact]
public void ResolveAckTarget_EmptyAckCommentSubtag_IsNotAckable()
{
AlarmSubtagTarget noAck = BuildTarget();
noAck.AckCommentSubtag = string.Empty;
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = new SubtagAlarmConsumer(source, new[] { noAck });
Assert.Equal(
AlarmAckOutcome.NotAckable,
consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea").Failure);
Assert.Equal(
AlarmAckOutcome.NotAckable,
consumer.ResolveAckTarget(SyntheticAlarmGuid.ForReference(Reference)).Failure);
} }
/// <summary>Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID.</summary> /// <summary>Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID.</summary>
@@ -160,11 +163,11 @@ public sealed class SubtagAlarmConsumerTests
/// <summary> /// <summary>
/// Verifies that when two alarm targets share a prefix (e.g. Level.Hi vs Level.HiHi), /// Verifies that when two alarm targets share a prefix (e.g. Level.Hi vs Level.HiHi),
/// AcknowledgeByName routes each ack to its own ack-comment subtag and never /// ResolveAckTargetByName resolves each to its own ack-comment subtag and never
/// conflates the shorter name with the longer one. /// conflates the shorter name with the longer one.
/// </summary> /// </summary>
[Fact] [Fact]
public void AcknowledgeByName_PrefixNameDoesNotFalseMatch() public void ResolveAckTargetByName_PrefixNameDoesNotFalseMatch()
{ {
const string ReferenceHi = "Galaxy!Area.Tank01.Level.Hi"; const string ReferenceHi = "Galaxy!Area.Tank01.Level.Hi";
const string ReferenceHiHi = "Galaxy!Area.Tank01.Level.HiHi"; const string ReferenceHiHi = "Galaxy!Area.Tank01.Level.HiHi";
@@ -195,42 +198,19 @@ public sealed class SubtagAlarmConsumerTests
source, new[] { targetHi, targetHiHi }); source, new[] { targetHi, targetHiHi });
consumer.Subscribe(@"\\HOST\Galaxy!Area"); consumer.Subscribe(@"\\HOST\Galaxy!Area");
// Ack the shorter name — must write to the shorter target's subtag only. Assert.Equal(AckCommentHi, consumer.ResolveAckTargetByName("Tank01.Level.Hi", "Galaxy", "Area").ItemAddress);
int rcHi = consumer.AcknowledgeByName( Assert.Equal(AckCommentHiHi, consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "Area").ItemAddress);
alarmName: "Tank01.Level.Hi", Assert.Equal(
providerName: "Galaxy", AlarmAckOutcome.AlarmNotFound,
groupName: "Area", consumer.ResolveAckTargetByName("Tank01.Level.H", "Galaxy", "Area").Failure);
ackComment: "ack hi",
ackOperatorName: "op", ackOperatorNode: "WS01",
ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
Assert.Equal(0, rcHi);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentHi, source.LastWrite!.Value.Address);
Assert.Equal("ack hi", source.LastWrite.Value.Value);
// Ack the longer name — must write to the longer target's subtag.
int rcHiHi = consumer.AcknowledgeByName(
alarmName: "Tank01.Level.HiHi",
providerName: "Galaxy",
groupName: "Area",
ackComment: "ack hihi",
ackOperatorName: "op", ackOperatorNode: "WS01",
ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
Assert.Equal(0, rcHiHi);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentHiHi, source.LastWrite!.Value.Address);
Assert.Equal("ack hihi", source.LastWrite.Value.Value);
} }
/// <summary> /// <summary>
/// Verifies AcknowledgeByGuid resolves the synthetic GUID (computed from /// Verifies ResolveAckTarget resolves the synthetic GUID (computed from
/// the alarm's full reference) to the correct target and writes the comment /// the alarm's full reference) to that target's ack-comment subtag.
/// to that target's ack-comment subtag.
/// </summary> /// </summary>
[Fact] [Fact]
public void AcknowledgeByGuid_WritesCommentToAckCommentSubtag() public void ResolveAckTarget_SyntheticGuid_ReturnsAckCommentSubtag()
{ {
FakeSource source = new FakeSource(); FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source); using SubtagAlarmConsumer consumer = BuildConsumer(source);
@@ -246,41 +226,21 @@ public sealed class SubtagAlarmConsumerTests
Guid syntheticGuid = emitted!.Record.AlarmGuid; Guid syntheticGuid = emitted!.Record.AlarmGuid;
Assert.NotEqual(Guid.Empty, syntheticGuid); Assert.NotEqual(Guid.Empty, syntheticGuid);
int rc = consumer.AcknowledgeByGuid( AlarmAckTarget target = consumer.ResolveAckTarget(syntheticGuid);
alarmGuid: syntheticGuid,
ackComment: "guid ack",
ackOperatorName: "op",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Operator");
Assert.Equal(0, rc); Assert.True(target.Found);
Assert.NotNull(source.LastWrite); Assert.Equal(AckCommentSubtag, target.ItemAddress);
Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
Assert.Equal("guid ack", source.LastWrite.Value.Value);
} }
/// <summary> /// <summary>Verifies ResolveAckTarget reports AlarmNotFound for a GUID unknown to the consumer.</summary>
/// Verifies AcknowledgeByGuid returns non-zero and performs no write when
/// the supplied GUID is not known to the consumer.
/// </summary>
[Fact] [Fact]
public void AcknowledgeByGuid_UnknownGuid_ReturnsNonZero() public void ResolveAckTarget_UnknownGuid_IsNotFound()
{ {
FakeSource source = new FakeSource(); FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source); using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea"); consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByGuid( Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
alarmGuid: Guid.NewGuid(),
ackComment: "should not write",
ackOperatorName: "op",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Operator");
Assert.NotEqual(0, rc);
Assert.Null(source.LastWrite);
} }
private sealed class FakeSource : ISubtagAlarmSource private sealed class FakeSource : ISubtagAlarmSource
@@ -291,21 +251,12 @@ public sealed class SubtagAlarmConsumerTests
/// <summary>Gets the subtag addresses passed to <see cref="Advise"/>.</summary> /// <summary>Gets the subtag addresses passed to <see cref="Advise"/>.</summary>
public List<string> Advised { get; } = new List<string>(); public List<string> Advised { get; } = new List<string>();
/// <summary>Gets the most recent (address, value) pair passed to <see cref="Write"/>.</summary>
public (string Address, object? Value)? LastWrite { get; private set; }
/// <inheritdoc /> /// <inheritdoc />
public void Advise(IReadOnlyCollection<string> itemAddresses) public void Advise(IReadOnlyCollection<string> itemAddresses)
{ {
Advised.AddRange(itemAddresses); Advised.AddRange(itemAddresses);
} }
/// <inheritdoc />
public void Write(string itemAddress, object? value)
{
LastWrite = (itemAddress, value);
}
/// <summary>Raises a <see cref="SubtagValueChange"/> for the given subtag.</summary> /// <summary>Raises a <see cref="SubtagValueChange"/> for the given subtag.</summary>
/// <param name="address">The subtag address whose value changed.</param> /// <param name="address">The subtag address whose value changed.</param>
/// <param name="value">The new value.</param> /// <param name="value">The new value.</param>
@@ -801,4 +801,81 @@ public sealed class WnWrapAlarmConsumerXmlTests
ProviderName = "Galaxy", ProviderName = "Galaxy",
}; };
} }
[Fact]
public void ParseSnapshotXml_WithAckElements_PopulatesAckFields()
{
const string xml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\"><ALARM><GUID>2D8A9BE88FE04B0F8D996BB4D56620C7</GUID>" +
"<DATE>2026/10/19</DATE><TIME>18:2:53.812</TIME><GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
"<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE><PROVIDER_NAME>Galaxy</PROVIDER_NAME><GROUP>TestArea</GROUP>" +
"<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME><TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
"<PRIORITY>500</PRIORITY><STATE>ACK_ALM</STATE><OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
"<ACK_DOMAIN_NAME>ArchestrA</ACK_DOMAIN_NAME><ACK_OPERATOR_NODE>mxa</ACK_OPERATOR_NODE>" +
"<ACK_OPERATOR_NAME>Administrator</ACK_OPERATOR_NAME><ALARM_COMMENT>Test alarm #1</ALARM_COMMENT>" +
"<ACK_COMMENT>mgack-authwrite 180714</ACK_COMMENT></ALARM></ALARM_RECORDS>";
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
MxAlarmSnapshotRecord record = Assert.Single(records.Values);
Assert.Equal("Administrator", record.AckOperatorName);
Assert.Equal("ArchestrA", record.AckOperatorDomain);
Assert.Equal("mxa", record.AckOperatorNode);
Assert.Equal("mgack-authwrite 180714", record.AckComment);
Assert.Equal("Test alarm #1", record.AlarmComment);
}
/// <summary>A Galaxy-provider record resolves to its <c>&lt;tag&gt;.AckMsg</c> item; an unknown GUID is not found.</summary>
[Fact]
public void ResolveAckTarget_GalaxyRecord_ReturnsAckMsgItem()
{
Guid guid = Guid.NewGuid();
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
{
AlarmGuid = guid, ProviderName = "Galaxy", Group = "TestArea", TagName = "TestMachine_001.TestAlarm001", State = MxAlarmStateKind.UnackAlm,
});
AlarmAckTarget target = consumer.ResolveAckTarget(guid);
Assert.True(target.Found);
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", target.ItemAddress);
Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
}
/// <summary>A non-Galaxy provider has no MXAccess ack attribute, so it is not ackable.</summary>
[Fact]
public void ResolveAckTarget_NonGalaxyProvider_IsNotAckable()
{
Guid guid = Guid.NewGuid();
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
{
AlarmGuid = guid, ProviderName = "InTouch", Group = "G", TagName = "Tag1", State = MxAlarmStateKind.UnackAlm,
});
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTarget(guid).Failure);
}
/// <summary>By-name resolution needs no snapshot: a Galaxy name maps straight to its AckMsg item.</summary>
[Fact]
public void ResolveAckTargetByName_Galaxy_ReturnsAckMsgItem()
{
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot();
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg",
consumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea").ItemAddress);
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTargetByName("Tag1", "InTouch", "G").Failure);
}
private static WnWrapAlarmConsumer CreateConsumerWithSnapshot(params MxAlarmSnapshotRecord[] records)
{
WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(1024);
Dictionary<Guid, MxAlarmSnapshotRecord> next = new Dictionary<Guid, MxAlarmSnapshotRecord>();
foreach (MxAlarmSnapshotRecord record in records)
{
next[record.AlarmGuid] = record;
}
consumer.FoldFetch(next, truncated: false, out _);
return consumer;
}
} }
@@ -2,7 +2,7 @@
// //
// Validates the subtag-fallback pipeline against a real Galaxy + MXAccess install: // Validates the subtag-fallback pipeline against a real Galaxy + MXAccess install:
// LmxSubtagAlarmSource (own LMXProxyServerClass) -> // LmxSubtagAlarmSource (own LMXProxyServerClass) ->
// SubtagAlarmConsumer (state machine + AcknowledgeByName write) -> // SubtagAlarmConsumer (state machine + ResolveAckTargetByName) + AlarmAckWriter (AckMsg write) ->
// synthesized MxAlarmTransitionEvent (Raise / Clear, Degraded=true, SyntheticGuid) // synthesized MxAlarmTransitionEvent (Raise / Clear, Degraded=true, SyntheticGuid)
// //
// FIELD NAMES CONFIRMED: InAlarm/Acked/AckMsg/Priority are the confirmed AVEVA // FIELD NAMES CONFIRMED: InAlarm/Acked/AckMsg/Priority are the confirmed AVEVA
@@ -49,8 +49,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
/// synthesize <see cref="MxAlarmTransitionEvent"/> records from Galaxy /// synthesize <see cref="MxAlarmTransitionEvent"/> records from Galaxy
/// alarm subtags using the confirmed AVEVA <c>AlarmExtension</c> field /// alarm subtags using the confirmed AVEVA <c>AlarmExtension</c> field
/// names (InAlarm/Acked/AckMsg/Priority), and that /// names (InAlarm/Acked/AckMsg/Priority), and that
/// <see cref="SubtagAlarmConsumer.AcknowledgeByName"/> writes the /// <see cref="SubtagAlarmConsumer.ResolveAckTargetByName"/> resolves the
/// ack-comment subtag (AckMsg) successfully. /// ack-comment subtag (AckMsg) that <see cref="AlarmAckWriter"/> then
/// writes successfully.
/// ///
/// Skip-gated; flip <c>Skip=null</c> on the dev rig with an alarm being /// Skip-gated; flip <c>Skip=null</c> on the dev rig with an alarm being
/// driven (flip script or a manual operator/IDE toggle of the alarm /// driven (flip script or a manual operator/IDE toggle of the alarm
@@ -119,7 +120,7 @@ public sealed class AlarmSubtagLiveSmokeTests
/// real <c>LMXProxyServerClass</c>, collects a Raise then a Clear /// real <c>LMXProxyServerClass</c>, collects a Raise then a Clear
/// transition synthesized by <see cref="SubtagAlarmConsumer"/>, confirms /// transition synthesized by <see cref="SubtagAlarmConsumer"/>, confirms
/// the Degraded flag and synthetic GUID are stamped, then /// the Degraded flag and synthetic GUID are stamped, then
/// AcknowledgeByName and verifies the ack-comment write returns 0. /// acknowledges through the resolved ack-comment subtag and verifies the write succeeds.
/// </summary> /// </summary>
[Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + an alarm being driven (flip script or manual operator/IDE toggle of the alarm attribute). Subtag fallback path. Field names confirmed (InAlarm/Acked/AckMsg/Priority); live-validate runtime path resolves without intermediate alarm-condition segment.")] [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + an alarm being driven (flip script or manual operator/IDE toggle of the alarm attribute). Subtag fallback path. Field names confirmed (InAlarm/Acked/AckMsg/Priority); live-validate runtime path resolves without intermediate alarm-condition segment.")]
public void SubtagFallback_FullPipelineRoundTrip_SynthesizesRaiseAndAcknowledges() public void SubtagFallback_FullPipelineRoundTrip_SynthesizesRaiseAndAcknowledges()
@@ -257,17 +258,9 @@ public sealed class AlarmSubtagLiveSmokeTests
Log("InAlarm subtag delivered a value: " + inAlarmResolved); Log("InAlarm subtag delivered a value: " + inAlarmResolved);
// Decisive write: AckMsg. Returns 0 only if the address resolves and is writable. // Decisive write: AckMsg. Returns 0 only if the address resolves and is writable.
Log("AcknowledgeByName (writes AckMsg subtag) ..."); Log("Acknowledge (writes AckMsg subtag via AlarmAckWriter) ...");
int rc = consumer.AcknowledgeByName( int rc = AckViaWriter(consumer, "subtag-resolution-probe").NativeStatus;
alarmName: AlarmTagName, Log(string.Format("Ack (AckMsg write) -> rc={0}", rc));
providerName: Provider,
groupName: Group,
ackComment: "subtag-resolution-probe",
ackOperatorName: Environment.UserName,
ackOperatorNode: Environment.MachineName,
ackOperatorDomain: Environment.UserDomainName ?? string.Empty,
ackOperatorFullName: Environment.UserName);
Log(string.Format("AcknowledgeByName(AckMsg write) -> rc={0}", rc));
DateTime settle = DateTime.UtcNow + TimeSpan.FromSeconds(2); DateTime settle = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (DateTime.UtcNow < settle) while (DateTime.UtcNow < settle)
@@ -392,21 +385,13 @@ public sealed class AlarmSubtagLiveSmokeTests
Assert.True(foundInSnapshot, Assert.True(foundInSnapshot,
string.Format("Active alarm snapshot must contain '{0}' after a Raise.", AlarmFullReference)); string.Format("Active alarm snapshot must contain '{0}' after a Raise.", AlarmFullReference));
// 3. AcknowledgeByName — writes the ack-comment subtag. // 3. Resolve the ack-comment subtag and write it as the operator.
// The dispatcher derives (alarmName, provider, group) via the same // The dispatcher derives (alarmName, provider, group) via the same
// TryParseReference logic as AlarmsLiveSmokeTests. // TryParseReference logic as AlarmsLiveSmokeTests.
Log("Calling AcknowledgeByName ..."); Log("Acknowledging via AlarmAckWriter ...");
int rc = consumer.AcknowledgeByName( int rc = AckViaWriter(consumer, "subtag-fallback-smoke ack").NativeStatus;
alarmName: AlarmTagName,
providerName: Provider,
groupName: Group,
ackComment: "subtag-fallback-smoke ack",
ackOperatorName: Environment.UserName,
ackOperatorNode: Environment.MachineName,
ackOperatorDomain: Environment.UserDomainName ?? string.Empty,
ackOperatorFullName: Environment.UserName);
Log(string.Format("AcknowledgeByName -> rc={0}", rc)); Log(string.Format("Ack -> rc={0}", rc));
Assert.Equal(0, rc); Assert.Equal(0, rc);
// 4. Wait for a Clear or Acknowledge transition to confirm the state // 4. Wait for a Clear or Acknowledge transition to confirm the state
@@ -501,6 +486,29 @@ public sealed class AlarmSubtagLiveSmokeTests
/// Mirrors the pump pattern documented in /// Mirrors the pump pattern documented in
/// <c>docs/MxAccessWorkerInstanceDesign.md</c>. /// <c>docs/MxAccessWorkerInstanceDesign.md</c>.
/// </summary> /// </summary>
/// <summary>
/// Resolves the probe alarm's ack item through the consumer and writes it
/// with <see cref="AlarmAckWriter"/> as the operator named by
/// <c>MXGATEWAY_PROBE_ACK_USER</c>/<c>MXGATEWAY_PROBE_ACK_PASSWORD</c>
/// (default: current user, empty password).
/// </summary>
private static AlarmAckResult AckViaWriter(SubtagAlarmConsumer consumer, string comment)
{
AlarmAckTarget target = consumer.ResolveAckTargetByName(AlarmTagName, Provider, Group);
if (!target.Found)
{
return AlarmAckResult.FromTarget(target);
}
using AlarmAckWriter writer = new AlarmAckWriter(
new MxAccessComObjectFactory(), PumpMessages, TimeSpan.FromSeconds(5), AlarmAckWriter.DefaultClientName);
return writer.Acknowledge(target.ItemAddress, comment, new AlarmAckOperator
{
UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName,
Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty,
});
}
private static void PumpMessages() private static void PumpMessages()
{ {
NativeMethods.MSG msg; NativeMethods.MSG msg;
@@ -86,7 +86,12 @@ public sealed class AlarmsLiveSmokeTests
new WNWRAPCONSUMERLib.wwAlarmConsumerClass(), new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
maxAlarmsPerFetch: 1024); maxAlarmsPerFetch: 1024);
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); using AlarmAckWriter ackWriter = new AlarmAckWriter(
new MxAccessComObjectFactory(),
() => new ZB.MOM.WW.MxGateway.Worker.Sta.StaMessagePump().PumpPendingMessages(),
TimeSpan.FromSeconds(5),
AlarmAckWriter.DefaultClientName);
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, ackWriter);
Log("Constructed consumer + sink + dispatcher."); Log("Constructed consumer + sink + dispatcher.");
dispatcher.Subscribe(SubscriptionExpression); dispatcher.Subscribe(SubscriptionExpression);
@@ -139,26 +144,23 @@ public sealed class AlarmsLiveSmokeTests
$"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag."); $"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag.");
Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'"); Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'");
// Try the ack with real Windows identity. AVEVA's AlarmAckByName // The ack authenticates a Galaxy operator and writes <tag>.AckMsg.
// may reject synthetic operator strings; using the current process // Operator credentials come from the environment (never logged);
// identity gives the alarm-history a recognizable principal. // the user defaults to the current process identity.
string realUser = Environment.UserName; AlarmAckOperator ackOperator = new AlarmAckOperator
string realNode = Environment.MachineName; {
string realDomain = Environment.UserDomainName ?? string.Empty; UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName,
Log($"Ack identity: user='{realUser}' node='{realNode}' domain='{realDomain}'"); Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty,
};
Log($"Ack identity: {ackOperator}");
int rc = dispatcher.AcknowledgeByName( AlarmAckResult result = dispatcher.AcknowledgeByName(
alarmName: alarmName, alarmName, provider, group, "alarms-live-smoke ack", ackOperator);
providerName: provider, Log($"AcknowledgeByName -> outcome={result.Outcome} native={result.NativeStatus} diag='{result.Diagnostic}'");
groupName: group,
ackComment: "alarms-live-smoke ack",
ackOperatorName: realUser,
ackOperatorNode: realNode,
ackOperatorDomain: realDomain,
ackOperatorFullName: realUser);
Log($"AcknowledgeByName(real identity) -> rc={rc}");
Assert.Equal(0, rc); Assert.True(
result.Outcome == AlarmAckOutcome.Applied || result.Outcome == AlarmAckOutcome.Unconfirmed,
$"Ack outcome {result.Outcome}: {result.Diagnostic}");
// 4. Wait for the post-ack transition. With the alarm flipping every // 4. Wait for the post-ack transition. With the alarm flipping every
// 10s and the consumer polling every 500ms, the next state // 10s and the consumer polling every 500ms, the next state
@@ -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, options,
() => Process.GetCurrentProcess().Id, () => Process.GetCurrentProcess().Id,
new WorkerPipeSessionOptions(), new WorkerPipeSessionOptions(),
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)), () => new MxAccessStaSession((eq, affinity, comFactory, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump)),
logger, logger,
stream) stream)
{ {
@@ -98,7 +98,7 @@ public sealed class WorkerPipeSession
options, options,
processIdProvider, processIdProvider,
new WorkerPipeSessionOptions(), new WorkerPipeSessionOptions(),
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)), () => new MxAccessStaSession((eq, affinity, comFactory, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump)),
logger: null) logger: null)
{ {
} }
@@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession
// parameterless CompleteStartupHandshakeAsync is used without a // parameterless CompleteStartupHandshakeAsync is used without a
// prior factory call. // prior factory call.
_runtimeSession ??= new MxAccessStaSession( _runtimeSession ??= new MxAccessStaSession(
(eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)); (eq, affinity, comFactory, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump));
IWorkerRuntimeSession session = _runtimeSession; IWorkerRuntimeSession session = _runtimeSession;
try try
{ {
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Operator identity an alarm acknowledgement authenticates as. The password
/// is a raw credential: it is passed to MXAccess <c>AuthenticateUser</c> only
/// and must never be logged or echoed.
/// </summary>
public sealed class AlarmAckOperator
{
/// <summary>Gets or sets the operator user name.</summary>
public string UserName { get; set; } = string.Empty;
/// <summary>Gets or sets the optional domain for OS-authenticated galaxies.</summary>
public string Domain { get; set; } = string.Empty;
/// <summary>Gets or sets the operator password (credential-sensitive).</summary>
public string Password { get; set; } = string.Empty;
/// <summary>Gets the user string passed to <c>AuthenticateUser</c>: <c>Domain\UserName</c> when a domain is set.</summary>
public string VerifyUser => string.IsNullOrEmpty(Domain) ? UserName : Domain + "\\" + UserName;
/// <inheritdoc />
public override string ToString() => "AlarmAckOperator(" + VerifyUser + ")";
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Outcome of an alarm acknowledgement attempt.</summary>
public enum AlarmAckOutcome
{
/// <summary>The AckMsg write completed with an OK status.</summary>
Applied,
/// <summary>The write was sent but no OnWriteComplete arrived before the timeout.</summary>
Unconfirmed,
/// <summary>The alarm is not in the consumer's current view.</summary>
AlarmNotFound,
/// <summary>The alarm exists but has no MXAccess ack attribute (non-Galaxy provider or no ack subtag).</summary>
NotAckable,
/// <summary>AuthenticateUser rejected the operator credentials.</summary>
AuthenticationFailed,
/// <summary>The AckMsg write completed with a non-OK status.</summary>
WriteRejected,
}
@@ -0,0 +1,49 @@
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Result of an alarm acknowledgement.</summary>
public sealed class AlarmAckResult
{
private static readonly IReadOnlyList<MxStatusProxy> NoStatuses = new MxStatusProxy[0];
private AlarmAckResult(AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList<MxStatusProxy>? statuses)
{
Outcome = outcome;
NativeStatus = nativeStatus;
Diagnostic = diagnostic;
Statuses = statuses ?? NoStatuses;
}
/// <summary>Gets the outcome.</summary>
public AlarmAckOutcome Outcome { get; }
/// <summary>Gets the native status: 0 for applied/unconfirmed, else the HRESULT or status detail.</summary>
public int NativeStatus { get; }
/// <summary>Gets a diagnostic message. Never contains credentials.</summary>
public string Diagnostic { get; }
/// <summary>Gets the OnWriteComplete statuses, empty when none was observed.</summary>
public IReadOnlyList<MxStatusProxy> Statuses { get; }
/// <summary>Creates a result.</summary>
/// <param name="outcome">The outcome.</param>
/// <param name="nativeStatus">The native status.</param>
/// <param name="diagnostic">A credential-free diagnostic.</param>
/// <param name="statuses">Observed write statuses.</param>
/// <returns>The result.</returns>
public static AlarmAckResult Create(
AlarmAckOutcome outcome,
int nativeStatus,
string diagnostic,
IReadOnlyList<MxStatusProxy>? statuses = null) =>
new AlarmAckResult(outcome, nativeStatus, diagnostic ?? string.Empty, statuses);
/// <summary>Creates the result for an unresolved ack target.</summary>
/// <param name="target">The unresolved target.</param>
/// <returns>The result.</returns>
public static AlarmAckResult FromTarget(AlarmAckTarget target) =>
new AlarmAckResult(target.Failure, -1, target.Reason, null);
}
@@ -0,0 +1,42 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>The MXAccess item an acknowledgement writes, or why none exists.</summary>
public sealed class AlarmAckTarget
{
private AlarmAckTarget(string itemAddress, AlarmAckOutcome failure, string reason)
{
ItemAddress = itemAddress;
Failure = failure;
Reason = reason;
}
/// <summary>Gets a value indicating whether an ack item was resolved.</summary>
public bool Found => ItemAddress.Length > 0;
/// <summary>Gets the ack item address (e.g. <c>TestMachine_001.TestAlarm001.AckMsg</c>), empty when not found.</summary>
public string ItemAddress { get; }
/// <summary>Gets the failure outcome when <see cref="Found"/> is false.</summary>
public AlarmAckOutcome Failure { get; }
/// <summary>Gets a human-readable reason when <see cref="Found"/> is false.</summary>
public string Reason { get; }
/// <summary>Creates a resolved target.</summary>
/// <param name="itemAddress">The ack item address.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget For(string itemAddress) =>
new AlarmAckTarget(itemAddress ?? string.Empty, AlarmAckOutcome.Applied, string.Empty);
/// <summary>Creates a not-found result.</summary>
/// <param name="reason">Why the alarm was not found.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget NotFound(string reason) =>
new AlarmAckTarget(string.Empty, AlarmAckOutcome.AlarmNotFound, reason);
/// <summary>Creates a not-ackable result.</summary>
/// <param name="reason">Why the alarm cannot be acknowledged over MXAccess.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget NotAckable(string reason) =>
new AlarmAckTarget(string.Empty, AlarmAckOutcome.NotAckable, reason);
}
@@ -0,0 +1,469 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ArchestrA.MxAccess;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Acknowledges alarms the way the AVEVA runtime records operator identity:
/// AuthenticateUser as the operator, then write the comment to the alarm's
/// <c>AckMsg</c> attribute with that user id. Owns its own
/// <c>LMXProxyServerClass</c> registered under the configured client name,
/// which the Historian records as the acknowledgement's OperatorNode.
/// Apartment-threaded: every call must run on the worker STA. The password
/// is passed to MXAccess only; it never reaches diagnostics.
/// </summary>
public sealed class AlarmAckWriter : IAlarmAckWriter
{
/// <summary>The ArchestrA alarm-primitive attribute whose write acknowledges the alarm.</summary>
public const string AckCommentAttribute = "AckMsg";
/// <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 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;
private int serverHandle;
private bool disposed;
/// <summary>Creates a writer that registers its own MXAccess server on first use.</summary>
/// <param name="factory">Creates the <c>LMXProxyServerClass</c>.</param>
/// <param name="pumpStep">Drains the STA message queue (OnWriteComplete is delivered by pumping).</param>
/// <param name="completionTimeout">Bounded wait for OnWriteComplete.</param>
/// <param name="clientName">MXAccess client name (Historian OperatorNode).</param>
public AlarmAckWriter(
IMxAccessComObjectFactory factory,
Action pumpStep,
TimeSpan completionTimeout,
string? clientName)
{
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
this.completionTimeout = completionTimeout;
this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!;
maxCachedItems = DefaultMaxCachedItems;
completionCache = new MxAccessWriteCompletionCache();
}
/// <summary>Test seam: uses an already-constructed server and completion cache.</summary>
internal AlarmAckWriter(
IMxAccessServer server,
MxAccessWriteCompletionCache completionCache,
Action pumpStep,
TimeSpan completionTimeout,
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));
this.completionTimeout = completionTimeout;
this.clientName = clientName;
serverHandle = server.Register(clientName);
}
/// <summary>Resolves the configured client name from the launcher environment.</summary>
/// <returns>The client name, or <see cref="DefaultClientName"/>.</returns>
public static string ResolveClientName()
{
string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.AlarmAckClientName);
return string.IsNullOrWhiteSpace(value) ? DefaultClientName : value!.Trim();
}
/// <inheritdoc />
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmAckWriter));
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 = Interop("AuthenticateUser", () => mx.AuthenticateUser(serverHandle, verifyUser, password));
}
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 '{verifyUser}' (HRESULT 0x{ex.HResult:X8}).");
}
if (userId == 0)
{
return AlarmAckResult.Create(
AlarmAckOutcome.AuthenticationFailed,
-1,
$"Operator authentication failed for '{verifyUser}' (no user id returned).");
}
ulong baselineVersion = 0;
int itemHandle = 0;
LinkedListNode<AckItem>? node;
bool cached = itemHandles.TryGetValue(itemAddress, out node);
try
{
if (cached && node!.Value.Dirty)
{
// 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.
Interop("Advise", () => mx.Advise(serverHandle, added));
}
pumpStep();
baselineVersion = completionCache.CurrentVersion(serverHandle, itemHandle);
int target = itemHandle;
Interop("Write", () => mx.Write(serverHandle, target, commentValue, userId));
}
catch (AckInteropException ex)
{
int hresult = ex.HResult;
if (itemHandles.TryGetValue(itemAddress, out LinkedListNode<AckItem>? failed))
{
DropItem(mx, failed!);
}
if (IsServerGone(hresult))
{
TearDownServer();
}
// Diagnostic names the step and HRESULT only: exception text can echo the item or operator.
return AlarmAckResult.Create(
AlarmAckOutcome.WriteRejected,
hresult,
$"AckMsg {ex.Step} failed (HRESULT 0x{hresult:X8}).");
}
ulong baseline = baselineVersion;
if (!completionCache.TryWaitForCompletion(
serverHandle,
itemHandle,
baseline,
DateTime.UtcNow + completionTimeout,
pumpStep,
out RepeatedField<MxStatusProxy> statuses))
{
node!.Value.Dirty = true;
return AlarmAckResult.Create(
AlarmAckOutcome.Unconfirmed,
0,
$"AckMsg write to '{itemAddress}' sent; OnWriteComplete not observed within {completionTimeout.TotalMilliseconds:F0} ms.");
}
foreach (MxStatusProxy status in statuses)
{
if (status.Category != Contracts.Proto.MxStatusCategory.Ok)
{
return AlarmAckResult.Create(
AlarmAckOutcome.WriteRejected,
status.Detail != 0 ? status.Detail : -1,
$"AckMsg write to '{itemAddress}' rejected: category {status.Category}, detail {status.Detail}.",
statuses);
}
}
return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, statuses);
}
/// <inheritdoc />
public void Dispose()
{
if (disposed) return;
disposed = true;
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
|| hresult == unchecked((int)0x80010105); // RPC_E_SERVERFAULT
/// <summary>Releases the MXAccess registration so the next call re-registers (also used by Dispose).</summary>
private void TearDownServer()
{
if (comEventSource is not null)
{
try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
}
IMxAccessServer? mx = server;
if (mx is not null)
{
foreach (AckItem item in itemLru)
{
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;
comObject = null;
if (toRelease is not null && Marshal.IsComObject(toRelease))
{
try { Marshal.FinalReleaseComObject(toRelease); } catch { /* best effort */ }
}
}
/// <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;
if (factory is null)
{
throw new InvalidOperationException("MXAccess server is gone and no COM factory is available to re-register.");
}
object created = factory.Create() ?? throw new InvalidOperationException("MXAccess COM factory returned null.");
try
{
comObject = created;
if (created is LMXProxyServerClass comServer)
{
comEventSource = comServer;
comServer.OnWriteComplete += OnWriteComplete;
}
IMxAccessServer mx = new MxAccessComServer(created);
serverHandle = mx.Register(clientName);
server = mx;
return mx;
}
catch
{
if (comEventSource is not null)
{
try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
comEventSource = null;
}
if (Marshal.IsComObject(created))
{
try { Marshal.FinalReleaseComObject(created); } catch { /* best effort */ }
}
comObject = null;
throw;
}
}
private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] pVars)
{
RepeatedField<MxStatusProxy> rows = new RepeatedField<MxStatusProxy>();
rows.AddRange(statusConverter.ConvertMany(pVars));
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; }
}
}
@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
@@ -39,9 +40,13 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
private readonly Action? threadAffinityCheck; private readonly Action? threadAffinityCheck;
private readonly IMxAccessComObjectFactory? comFactory; private readonly IMxAccessComObjectFactory? comFactory;
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory; private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
private readonly Func<IAlarmAckWriter>? ackWriterFactory;
private readonly Action? ackPumpStep;
private readonly MxAccessEventMapper mapper = new MxAccessEventMapper(); private readonly MxAccessEventMapper mapper = new MxAccessEventMapper();
private readonly object syncRoot = new object(); private readonly object syncRoot = new object();
private readonly IAlarmAckWriter deferredAckWriter;
private AlarmDispatcher? dispatcher; private AlarmDispatcher? dispatcher;
private IAlarmAckWriter? ackWriter;
private FailoverAlarmConsumer? failoverConsumer; private FailoverAlarmConsumer? failoverConsumer;
private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler; private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler;
private string subscribeSessionId = string.Empty; private string subscribeSessionId = string.Empty;
@@ -117,18 +122,40 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
/// <paramref name="comFactory"/>. Tests inject a fake so they need no /// <paramref name="comFactory"/>. Tests inject a fake so they need no
/// live COM factory. /// live COM factory.
/// </param> /// </param>
/// <param name="ackWriterFactory">
/// Optional seam that builds the <see cref="IAlarmAckWriter"/> used for
/// every acknowledgement. Defaults to an <see cref="AlarmAckWriter"/> over
/// <paramref name="comFactory"/> that registers its own MXAccess server
/// (client name from <see cref="AlarmAckWriter.ResolveClientName"/>).
/// The writer is created once per handler, on the first acknowledgement
/// (so a subscription never fails because ack is unavailable), survives
/// 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( public AlarmCommandHandler(
MxAccessEventQueue eventQueue, MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory, Func<IMxAccessAlarmConsumer> consumerFactory,
Action? threadAffinityCheck, Action? threadAffinityCheck,
IMxAccessComObjectFactory? comFactory, IMxAccessComObjectFactory? comFactory,
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory) Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
Func<IAlarmAckWriter>? ackWriterFactory = null,
Action? ackPumpStep = null)
{ {
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory)); this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
this.threadAffinityCheck = threadAffinityCheck; this.threadAffinityCheck = threadAffinityCheck;
this.comFactory = comFactory; this.comFactory = comFactory;
this.standbyFactory = standbyFactory; this.standbyFactory = standbyFactory;
this.ackWriterFactory = ackWriterFactory;
this.ackPumpStep = ackPumpStep;
this.deferredAckWriter = new DeferredAckWriter(this);
} }
/// <summary>Gets a value indicating whether the handler is subscribed.</summary> /// <summary>Gets a value indicating whether the handler is subscribed.</summary>
@@ -170,7 +197,7 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
} }
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper);
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId); dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, deferredAckWriter);
try try
{ {
dispatcher.Subscribe(subscriptionExpression); dispatcher.Subscribe(subscriptionExpression);
@@ -305,47 +332,31 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
} }
/// <inheritdoc /> /// <inheritdoc />
public int Acknowledge( public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
{ {
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
threadAffinityCheck?.Invoke(); threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow(); AlarmDispatcher d = GetDispatcherOrThrow();
return d.Acknowledge( return d.Acknowledge(alarmGuid, comment ?? string.Empty, ackOperator);
alarmGuid,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckResult AcknowledgeByName(
string alarmName, string alarmName,
string providerName, string providerName,
string groupName, string groupName,
string comment, string comment,
string operatorUser, AlarmAckOperator ackOperator)
string operatorNode,
string operatorDomain,
string operatorFullName)
{ {
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
threadAffinityCheck?.Invoke(); threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow(); AlarmDispatcher d = GetDispatcherOrThrow();
return d.AcknowledgeByName( return d.AcknowledgeByName(
alarmName ?? string.Empty, alarmName ?? string.Empty,
providerName ?? string.Empty, providerName ?? string.Empty,
groupName ?? string.Empty, groupName ?? string.Empty,
comment ?? string.Empty, comment ?? string.Empty,
operatorUser ?? string.Empty, ackOperator);
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -380,6 +391,62 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
d?.PollOnce(); d?.PollOnce();
} }
private IAlarmAckWriter GetOrCreateAckWriter()
{
lock (syncRoot)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (ackWriter is not null) return ackWriter;
if (ackWriterFactory is not null)
{
ackWriter = ackWriterFactory() ?? throw new InvalidOperationException("Alarm ack writer factory returned null.");
return ackWriter;
}
if (comFactory is null)
{
throw new InvalidOperationException(
"Alarm acknowledgement requires an IMxAccessComObjectFactory; the alarm command handler was constructed without one.");
}
// 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,
pump,
MxAccessStaSession.ResolveWriteCompletionTimeout(),
AlarmAckWriter.ResolveClientName());
return ackWriter;
}
}
/// <summary>
/// The writer handed to each <see cref="AlarmDispatcher"/>. Resolves the
/// handler's real writer on first acknowledgement so the writer (and its
/// MXAccess server registration) is created only when an ack happens and
/// outlives dispatcher churn. Disposing it is a no-op; the handler owns
/// the real writer.
/// </summary>
private sealed class DeferredAckWriter : IAlarmAckWriter
{
private readonly AlarmCommandHandler owner;
public DeferredAckWriter(AlarmCommandHandler owner)
{
this.owner = owner;
}
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) =>
owner.GetOrCreateAckWriter().Acknowledge(itemAddress, comment, ackOperator);
public void Dispose()
{
}
}
private AlarmDispatcher GetDispatcherOrThrow() private AlarmDispatcher GetDispatcherOrThrow()
{ {
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler)); if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
@@ -400,5 +467,13 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
Unsubscribe(); Unsubscribe();
IAlarmAckWriter? writerToDispose;
lock (syncRoot)
{
writerToDispose = ackWriter;
ackWriter = null;
}
try { writerToDispose?.Dispose(); } catch { /* swallow */ }
} }
} }
@@ -18,7 +18,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// worker's IPC layer issues <c>SubscribeAlarmsCommand</c> / /// worker's IPC layer issues <c>SubscribeAlarmsCommand</c> /
/// <c>AcknowledgeAlarmCommand</c> / <c>QueryActiveAlarmsCommand</c> /// <c>AcknowledgeAlarmCommand</c> / <c>QueryActiveAlarmsCommand</c>
/// through <see cref="AlarmCommandHandler"/>, which owns one /// through <see cref="AlarmCommandHandler"/>, which owns one
/// dispatcher per session. /// dispatcher per session. Acknowledgement resolves the alarm to its
/// MXAccess ack item through the consumer and hands the write to the
/// <see cref="IAlarmAckWriter"/>, which authenticates as the operator.
/// </para> /// </para>
/// <para> /// <para>
/// Threading: <see cref="WnWrapAlarmConsumer"/> owns no internal /// Threading: <see cref="WnWrapAlarmConsumer"/> owns no internal
@@ -36,20 +38,29 @@ public sealed class AlarmDispatcher : IDisposable
private readonly MxAccessAlarmEventSink sink; private readonly MxAccessAlarmEventSink sink;
private readonly string sessionId; private readonly string sessionId;
private readonly EventHandler<MxAlarmTransitionEvent> handler; private readonly EventHandler<MxAlarmTransitionEvent> handler;
private readonly IAlarmAckWriter ackWriter;
private bool disposed; private bool disposed;
/// <summary>Initializes a new alarm dispatcher for the given consumer, sink, and session ID.</summary> /// <summary>Initializes a new alarm dispatcher for the given consumer, sink, session ID and ack writer.</summary>
/// <param name="consumer">The alarm consumer.</param> /// <param name="consumer">The alarm consumer.</param>
/// <param name="sink">The alarm event sink.</param> /// <param name="sink">The alarm event sink.</param>
/// <param name="sessionId">The session identifier.</param> /// <param name="sessionId">The session identifier.</param>
/// <param name="ackWriter">
/// Writes the resolved ack item as the authenticated operator. Owned by
/// the caller (<see cref="AlarmCommandHandler"/>), which disposes it; the
/// dispatcher never does, so the writer's registered MXAccess server
/// outlives an unsubscribe/resubscribe cycle.
/// </param>
public AlarmDispatcher( public AlarmDispatcher(
IMxAccessAlarmConsumer consumer, IMxAccessAlarmConsumer consumer,
MxAccessAlarmEventSink sink, MxAccessAlarmEventSink sink,
string sessionId) string sessionId,
IAlarmAckWriter ackWriter)
{ {
this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer)); this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
this.sink = sink ?? throw new ArgumentNullException(nameof(sink)); this.sink = sink ?? throw new ArgumentNullException(nameof(sink));
this.sessionId = sessionId ?? string.Empty; this.sessionId = sessionId ?? string.Empty;
this.ackWriter = ackWriter ?? throw new ArgumentNullException(nameof(ackWriter));
// Sink.Attach is the seam that propagates the session id onto the // Sink.Attach is the seam that propagates the session id onto the
// proto SessionId field of every emitted MxEvent. Pass the consumer // proto SessionId field of every emitted MxEvent. Pass the consumer
// as the "associated COM object" — sink ignores the object reference // as the "associated COM object" — sink ignores the object reference
@@ -72,70 +83,35 @@ public sealed class AlarmDispatcher : IDisposable
consumer.Subscribe(subscription); consumer.Subscribe(subscription);
} }
/// <summary> /// <summary>Acknowledges the alarm with the given GUID by writing its ack item as the operator.</summary>
/// Forward an <c>AcknowledgeAlarm</c> request to the underlying
/// consumer's <c>AlarmAckByGUID</c>. Returns the AVEVA-native
/// status code (0 = success).
/// </summary>
/// <param name="alarmGuid">The alarm GUID.</param> /// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="ackComment">The acknowledgment comment.</param> /// <param name="ackComment">The acknowledgement comment.</param>
/// <param name="ackOperatorName">The operator name.</param> /// <param name="ackOperator">The operator to authenticate as.</param>
/// <param name="ackOperatorNode">The operator node.</param> /// <returns>The outcome.</returns>
/// <param name="ackOperatorDomain">The operator domain.</param> public AlarmAckResult Acknowledge(Guid alarmGuid, string ackComment, AlarmAckOperator ackOperator)
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
public int Acknowledge(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
return consumer.AcknowledgeByGuid( AlarmAckTarget target = consumer.ResolveAckTarget(alarmGuid);
alarmGuid, return target.Found
ackComment, ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
ackOperatorName, : AlarmAckResult.FromTarget(target);
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
} }
/// <summary> /// <summary>Acknowledges the alarm identified by name/provider/group as the operator.</summary>
/// Acknowledge an alarm by its (name, provider, group) tuple. /// <param name="alarmName">The tag/alarm name.</param>
/// Routes to the consumer's <c>AcknowledgeByName</c> path which /// <param name="providerName">The provider.</param>
/// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>. /// <param name="groupName">The group/area.</param>
/// </summary> /// <param name="ackComment">The acknowledgement comment.</param>
/// <param name="alarmName">The alarm name.</param> /// <param name="ackOperator">The operator to authenticate as.</param>
/// <param name="providerName">The provider name.</param> /// <returns>The outcome.</returns>
/// <param name="groupName">The group name.</param> public AlarmAckResult AcknowledgeByName(
/// <param name="ackComment">The acknowledgment comment.</param> string alarmName, string providerName, string groupName, string ackComment, AlarmAckOperator ackOperator)
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
public int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
return consumer.AcknowledgeByName( AlarmAckTarget target = consumer.ResolveAckTargetByName(alarmName, providerName, groupName);
alarmName, return target.Found
providerName, ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
groupName, : AlarmAckResult.FromTarget(target);
ackComment,
ackOperatorName,
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
} }
/// <summary> /// <summary>
@@ -193,6 +169,7 @@ public sealed class AlarmDispatcher : IDisposable
string fullReference = AlarmRecordTransitionMapper.ComposeFullReference( string fullReference = AlarmRecordTransitionMapper.ComposeFullReference(
record.ProviderName, record.Group, record.TagName); record.ProviderName, record.Group, record.TagName);
bool useAck = kind == AlarmTransitionKind.Acknowledge && HasAckAttribution(record);
sink.EnqueueTransition( sink.EnqueueTransition(
alarmFullReference: fullReference, alarmFullReference: fullReference,
@@ -202,15 +179,22 @@ public sealed class AlarmDispatcher : IDisposable
severity: record.Priority, severity: record.Priority,
originalRaiseTimestampUtc: null, originalRaiseTimestampUtc: null,
transitionTimestampUtc: record.TransitionTimestampUtc, transitionTimestampUtc: record.TransitionTimestampUtc,
operatorUser: record.OperatorName, operatorUser: useAck ? record.AckOperatorName : record.OperatorName,
operatorComment: record.AlarmComment, operatorComment: useAck ? record.AckComment : record.AlarmComment,
category: record.Group, category: record.Group,
description: string.Empty, description: string.Empty,
degraded: record.Degraded); degraded: record.Degraded);
} }
private static bool HasAckAttribution(MxAlarmSnapshotRecord record) =>
!string.IsNullOrEmpty(record.AckOperatorName) || !string.IsNullOrEmpty(record.AckComment);
private static bool IsAcked(MxAlarmStateKind state) =>
state == MxAlarmStateKind.AckAlm || state == MxAlarmStateKind.AckRtn;
private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record, bool truncated) private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record, bool truncated)
{ {
bool useAck = IsAcked(record.State) && HasAckAttribution(record);
ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot
{ {
AlarmFullReference = AlarmRecordTransitionMapper.ComposeFullReference( AlarmFullReference = AlarmRecordTransitionMapper.ComposeFullReference(
@@ -219,8 +203,8 @@ public sealed class AlarmDispatcher : IDisposable
AlarmTypeName = record.Type, AlarmTypeName = record.Type,
CurrentState = MapConditionState(record.State), CurrentState = MapConditionState(record.State),
Severity = record.Priority, Severity = record.Priority,
OperatorUser = record.OperatorName, OperatorUser = useAck ? record.AckOperatorName : record.OperatorName,
OperatorComment = record.AlarmComment, OperatorComment = useAck ? record.AckComment : record.AlarmComment,
Category = record.Group, Category = record.Group,
Description = string.Empty, Description = string.Empty,
Degraded = record.Degraded, Degraded = record.Degraded,
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <strong>Threading.</strong> Like its children, this type is driven /// <strong>Threading.</strong> Like its children, this type is driven
/// entirely on the worker's STA: <see cref="Subscribe"/>, /// entirely on the worker's STA: <see cref="Subscribe"/>,
/// <see cref="PollOnce"/>, <see cref="ProbeOnce"/>, and the /// <see cref="PollOnce"/>, <see cref="ProbeOnce"/>, and the
/// <c>AcknowledgeBy*</c> calls are all invoked from the apartment that /// <c>ResolveAckTarget*</c> calls are all invoked from the apartment that
/// owns the underlying COM objects. It owns no locks of its own and no /// owns the underlying COM objects. It owns no locks of its own and no
/// internal timer; the worker drives <see cref="PollOnce"/> on a timer. /// internal timer; the worker drives <see cref="PollOnce"/> on a timer.
/// </para> /// </para>
@@ -259,34 +259,17 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByGuid( public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
return ActiveChild.AcknowledgeByGuid( return ActiveChild.ResolveAckTarget(alarmGuid);
alarmGuid, ackComment, ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName);
} }
/// <inheritdoc /> /// <inheritdoc />
public int AcknowledgeByName( public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{ {
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer)); if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
return ActiveChild.AcknowledgeByName( return ActiveChild.ResolveAckTargetByName(alarmName, providerName, groupName);
alarmName, providerName, groupName, ackComment,
ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -0,0 +1,17 @@
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Acknowledges an alarm by writing its MXAccess ack item (<c>AckMsg</c>)
/// after authenticating as the operator. Must be called on the worker STA.
/// </summary>
public interface IAlarmAckWriter : IDisposable
{
/// <summary>Authenticates <paramref name="ackOperator"/> and writes <paramref name="comment"/> to <paramref name="itemAddress"/>.</summary>
/// <param name="itemAddress">The ack item, e.g. <c>Obj.Alarm.AckMsg</c>.</param>
/// <param name="comment">The acknowledgement comment.</param>
/// <param name="ackOperator">The operator to authenticate as.</param>
/// <returns>The outcome.</returns>
AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator);
}
@@ -35,44 +35,21 @@ public interface IAlarmCommandHandler : IDisposable
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary> /// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
void Unsubscribe(); void Unsubscribe();
/// <summary>Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success).</summary> /// <summary>Acknowledges an alarm by GUID as <paramref name="ackOperator"/>.</summary>
/// <param name="alarmGuid">The alarm GUID.</param> /// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="comment">The acknowledgment comment.</param> /// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
/// <param name="operatorUser">The operator user name.</param> /// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
/// <param name="operatorNode">The operator node name.</param> /// <returns>The acknowledgement outcome.</returns>
/// <param name="operatorDomain">The operator domain name.</param> AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator);
/// <param name="operatorFullName">The operator full name.</param>
/// <returns>AVEVA's native alarm status code (0 = success).</returns>
int Acknowledge(
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName);
/// <summary> /// <summary>Acknowledges an alarm by name/provider/group as <paramref name="ackOperator"/>.</summary>
/// Acknowledge a single alarm by (name, provider, group) — used when /// <param name="alarmName">The alarm (tag) name.</param>
/// the caller has the human-readable reference but not the GUID.
/// </summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param> /// <param name="providerName">The provider name.</param>
/// <param name="groupName">The group name.</param> /// <param name="groupName">The group name.</param>
/// <param name="comment">The acknowledgment comment.</param> /// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
/// <param name="operatorUser">The operator user name.</param> /// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
/// <param name="operatorNode">The operator node name.</param> /// <returns>The acknowledgement outcome.</returns>
/// <param name="operatorDomain">The operator domain name.</param> AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator);
/// <param name="operatorFullName">The operator full name.</param>
/// <returns>AVEVA's native alarm status code (0 = success).</returns>
int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName);
/// <summary> /// <summary>
/// Snapshot the currently-active alarm set, optionally scoped to a /// Snapshot the currently-active alarm set, optionally scoped to a
@@ -49,51 +49,19 @@ public interface IMxAccessAlarmConsumer : IDisposable
void Subscribe(string subscription); void Subscribe(string subscription);
/// <summary> /// <summary>
/// Acknowledges a single alarm with full operator-identity fidelity. /// Resolves the MXAccess item whose write acknowledges the alarm with the
/// Reaches AVEVA's native <c>AlarmAckByGUID</c>; operator /// given GUID (e.g. <c>Obj.Alarm.AckMsg</c>). Does not perform the ack.
/// user / node / domain / full-name and the comment land atomically
/// with the ack transition in the alarm-history log.
/// </summary> /// </summary>
/// <param name="alarmGuid">The alarm GUID.</param> /// <param name="alarmGuid">The alarm GUID as reported by this consumer.</param>
/// <param name="ackComment">The acknowledgment comment.</param> /// <returns>The resolved target or the reason none exists.</returns>
/// <param name="ackOperatorName">The operator name.</param> AlarmAckTarget ResolveAckTarget(Guid alarmGuid);
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName);
/// <summary> /// <summary>Resolves the ack item for an alarm identified by name, provider and group.</summary>
/// Acknowledge a single alarm by its (name, provider, group) tuple. /// <param name="alarmName">The tag/alarm name, e.g. <c>TestMachine_001.TestAlarm001</c>.</param>
/// Reaches AVEVA's <c>AlarmAckByName</c> on /// <param name="providerName">The provider (literal <c>Galaxy</c> for ArchestrA).</param>
/// <c>wwAlarmConsumerClass</c>; same alarm-history outcome as /// <param name="groupName">The area/group.</param>
/// <see cref="AcknowledgeByGuid"/>, used when the caller has the /// <returns>The resolved target or the reason none exists.</returns>
/// human-readable reference but not the canonical GUID. AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName);
/// </summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param>
/// <param name="groupName">The group name.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName);
/// <summary> /// <summary>
/// Returns the consumer's most recently parsed snapshot of currently /// Returns the consumer's most recently parsed snapshot of currently
@@ -16,9 +16,4 @@ public interface ISubtagAlarmSource : IDisposable
/// <summary>Begins advising the supplied MXAccess subtag item addresses.</summary> /// <summary>Begins advising the supplied MXAccess subtag item addresses.</summary>
/// <param name="itemAddresses">The subtag references to advise.</param> /// <param name="itemAddresses">The subtag references to advise.</param>
void Advise(IReadOnlyCollection<string> itemAddresses); void Advise(IReadOnlyCollection<string> itemAddresses);
/// <summary>Writes a value to the named MXAccess subtag (e.g. an ack request).</summary>
/// <param name="itemAddress">The subtag reference to write.</param>
/// <param name="value">The value to write.</param>
void Write(string itemAddress, object? value);
} }
@@ -9,8 +9,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// Production <see cref="ISubtagAlarmSource"/> backed by its own MXAccess /// Production <see cref="ISubtagAlarmSource"/> backed by its own MXAccess
/// <c>LMXProxyServerClass</c> COM object. It advises a set of alarm subtag /// <c>LMXProxyServerClass</c> COM object. It advises a set of alarm subtag
/// item addresses, normalizes each <c>OnDataChange</c> COM callback into a /// item addresses, normalizes each <c>OnDataChange</c> COM callback into a
/// <see cref="SubtagValueChange"/>, and supports writing a value (e.g. an /// <see cref="SubtagValueChange"/>. It is observe-only: alarm
/// ack-comment) to a subtag. /// acknowledgement goes through <see cref="AlarmAckWriter"/>, which
/// authenticates the operator on its own server.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -25,7 +26,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <strong>Threading.</strong> The <c>LMXProxyServerClass</c> CLSID is /// <strong>Threading.</strong> The <c>LMXProxyServerClass</c> CLSID is
/// apartment-threaded and delivers <c>OnDataChange</c> via the STA /// apartment-threaded and delivers <c>OnDataChange</c> via the STA
/// message pump. Every method on this type — the lazy <see cref="Advise"/> /// message pump. Every method on this type — the lazy <see cref="Advise"/>
/// creation, <see cref="Write"/>, and <see cref="Dispose"/> — must be /// creation and <see cref="Dispose"/> — must be
/// invoked on the worker's STA that created the source, exactly as /// invoked on the worker's STA that created the source, exactly as
/// <see cref="MxAccessSession"/> and <see cref="WnWrapAlarmConsumer"/> /// <see cref="MxAccessSession"/> and <see cref="WnWrapAlarmConsumer"/>
/// require. The <c>OnDataChange</c> COM event also arrives on that STA. /// require. The <c>OnDataChange</c> COM event also arrives on that STA.
@@ -47,9 +48,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
new Dictionary<int, string>(); new Dictionary<int, string>();
// Handles that were actually Advise()d, tracked separately from the added // Handles that were actually Advise()d, tracked separately from the added
// set so Dispose only UnAdvises advised items. Write() can AddItem a // set so Dispose only UnAdvises items this source advised.
// write-only subtag (e.g. an ack-comment that was never advised); calling
// UnAdvise on such a handle would be an unbalanced teardown.
private readonly HashSet<int> advisedItemHandles = new HashSet<int>(); private readonly HashSet<int> advisedItemHandles = new HashSet<int>();
private object? mxAccessComObject; private object? mxAccessComObject;
@@ -85,7 +84,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
/// <see cref="IMxAccessServer"/> with a pre-registered server handle, /// <see cref="IMxAccessServer"/> with a pre-registered server handle,
/// bypassing the live COM factory. Exposed via /// bypassing the live COM factory. Exposed via
/// <c>InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")</c> so tests can /// <c>InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")</c> so tests can
/// exercise <see cref="Advise"/>/<see cref="Write"/> and /// exercise <see cref="Advise"/> and
/// <see cref="HandleDataChange"/> without a live <c>LMXProxyServerClass</c>. /// <see cref="HandleDataChange"/> without a live <c>LMXProxyServerClass</c>.
/// No COM event source is wired in this mode — tests drive /// No COM event source is wired in this mode — tests drive
/// <see cref="HandleDataChange"/> directly to simulate an /// <see cref="HandleDataChange"/> directly to simulate an
@@ -140,29 +139,6 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
} }
} }
/// <inheritdoc />
public void Write(string itemAddress, object? value)
{
// Writes with MXAccess user id 0 — always an unsecured Write, never WriteSecured semantics.
if (itemAddress is null)
{
throw new ArgumentNullException(nameof(itemAddress));
}
ThrowIfDisposed();
EnsureRegistered();
IMxAccessServer mxServer = server!;
if (!itemHandlesByAddress.TryGetValue(itemAddress, out int itemHandle))
{
itemHandle = mxServer.AddItem(serverHandle, itemAddress);
itemHandlesByAddress[itemAddress] = itemHandle;
addressesByItemHandle[itemHandle] = itemAddress;
}
mxServer.Write(serverHandle, itemHandle, value, userId: 0);
}
/// <summary> /// <summary>
/// Normalizes an MXAccess <c>OnDataChange</c> callback into a /// Normalizes an MXAccess <c>OnDataChange</c> callback into a
/// <see cref="SubtagValueChange"/> and raises <see cref="ValueChanged"/> /// <see cref="SubtagValueChange"/> and raises <see cref="ValueChanged"/>
@@ -223,8 +199,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
{ {
foreach (KeyValuePair<int, string> entry in addressesByItemHandle) foreach (KeyValuePair<int, string> entry in addressesByItemHandle)
{ {
// Only UnAdvise handles that were actually advised; a write-only // Only UnAdvise handles that were actually advised.
// item (added by Write but never Advise'd) was never advised.
if (advisedItemHandles.Contains(entry.Key)) if (advisedItemHandles.Contains(entry.Key))
{ {
try { mxServer.UnAdvise(serverHandle, entry.Key); } catch { /* swallow — best effort */ } try { mxServer.UnAdvise(serverHandle, entry.Key); } catch { /* swallow — best effort */ }
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion; using ZB.MOM.WW.MxGateway.Worker.Conversion;
using ZB.MOM.WW.MxGateway.Worker.Sta; using ZB.MOM.WW.MxGateway.Worker.Sta;
@@ -912,30 +913,25 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
$"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'."); $"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'.");
} }
// operator_password is a raw credential: it goes into AlarmAckOperator for
// MXAccess AuthenticateUser and nowhere else. Never format the payload
// (its generated ToString renders every field) or the exception text.
try try
{ {
int rc = alarmCommandHandler.Acknowledge( AlarmAckResult result = alarmCommandHandler.Acknowledge(
alarmGuid, alarmGuid,
payload.Comment, payload.Comment,
payload.OperatorUser, new AlarmAckOperator
payload.OperatorNode,
payload.OperatorDomain,
payload.OperatorFullName);
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = rc;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
{ {
NativeStatus = rc, UserName = payload.OperatorUser,
}; Domain = payload.OperatorDomain,
if (rc != 0) Password = payload.OperatorPassword,
{ });
reply.DiagnosticMessage = $"AVEVA AlarmAckByGUID returned non-zero status {rc}."; return CreateAcknowledgeReply(command, result);
}
return reply;
} }
catch (Exception ex) catch (Exception ex)
{ {
return CreateAlarmFailureReply(command, ex); return CreateAcknowledgeFailureReply(command, ex);
} }
} }
@@ -958,35 +954,80 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required."); return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required.");
} }
// See ExecuteAcknowledgeAlarm: operator_password never leaves AlarmAckOperator.
try try
{ {
int rc = alarmCommandHandler.AcknowledgeByName( AlarmAckResult result = alarmCommandHandler.AcknowledgeByName(
payload.AlarmName, payload.AlarmName,
payload.ProviderName, payload.ProviderName,
payload.GroupName, payload.GroupName,
payload.Comment, payload.Comment,
payload.OperatorUser, new AlarmAckOperator
payload.OperatorNode,
payload.OperatorDomain,
payload.OperatorFullName);
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = rc;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
{ {
NativeStatus = rc, UserName = payload.OperatorUser,
}; Domain = payload.OperatorDomain,
if (rc != 0) Password = payload.OperatorPassword,
{ });
reply.DiagnosticMessage = $"AVEVA AlarmAckByName returned non-zero status {rc}."; return CreateAcknowledgeReply(command, result);
}
return reply;
} }
catch (Exception ex) catch (Exception ex)
{ {
return CreateAlarmFailureReply(command, ex); return CreateAcknowledgeFailureReply(command, ex);
} }
} }
private static MxCommandReply CreateAcknowledgeReply(StaCommand command, AlarmAckResult result)
{
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = result.NativeStatus;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = result.NativeStatus };
reply.Statuses.Add(result.Statuses);
reply.DiagnosticMessage = result.Diagnostic;
switch (result.Outcome)
{
case AlarmAckOutcome.AlarmNotFound:
case AlarmAckOutcome.NotAckable:
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.InvalidRequest, Message = result.Diagnostic };
break;
case AlarmAckOutcome.AuthenticationFailed:
case AlarmAckOutcome.WriteRejected:
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure, Message = result.Diagnostic };
break;
}
return reply;
}
/// <summary>
/// Maps an exception escaping an acknowledgement (e.g. the ack writer's
/// MXAccess Register failing, or no active subscription) to an
/// <see cref="ProtocolStatusCode.MxaccessFailure"/> reply. Only the
/// exception type and HRESULT are reported: exception text on this path
/// is not under our control and must not be able to echo operator input.
/// </summary>
private static MxCommandReply CreateAcknowledgeFailureReply(StaCommand command, Exception exception)
{
string diagnostic = string.Format(
CultureInfo.InvariantCulture,
"Alarm acknowledgement failed: {0} (HRESULT 0x{1:X8}).",
exception.GetType().FullName,
exception.HResult);
return new MxCommandReply
{
SessionId = command.SessionId,
CorrelationId = command.CorrelationId,
Kind = command.Kind,
Hresult = exception.HResult,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.MxaccessFailure,
Message = diagnostic,
},
DiagnosticMessage = diagnostic,
AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = exception.HResult },
};
}
private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command) private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command)
{ {
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand) if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand)
@@ -48,7 +48,10 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
private readonly IMxAccessEventSink eventSink; private readonly IMxAccessEventSink eventSink;
private readonly MxAccessEventQueue eventQueue; private readonly MxAccessEventQueue eventQueue;
private readonly StaRuntime staRuntime; 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 StaCommandDispatcher? commandDispatcher;
private MxAccessSession? session; private MxAccessSession? session;
private IAlarmCommandHandler? alarmCommandHandler; 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> /// <summary>
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom STA runtime and factory. /// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom STA runtime and factory.
/// </summary> /// </summary>
@@ -183,7 +203,9 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink)); this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); 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> /// <summary>
@@ -288,7 +310,8 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
alarmCommandHandler = alarmCommandHandlerFactory( alarmCommandHandler = alarmCommandHandlerFactory(
eventQueue, eventQueue,
EnsureOnAlarmConsumerThread, EnsureOnAlarmConsumerThread,
factory); factory,
() => staRuntime.PumpPendingMessages());
} }
commandDispatcher = new StaCommandDispatcher( commandDispatcher = new StaCommandDispatcher(
staRuntime, staRuntime,

Some files were not shown because too many files have changed in this diff Show More