Compare commits
318 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61c00d59a9 | |||
| 574369b666 | |||
| 9351ec1d2c | |||
| b9096f7c57 | |||
| ff397b27be | |||
| 3a3d9e8e06 | |||
| e5e7f1854f | |||
| 777fef9cad | |||
| 34a7453ac3 | |||
| 9354d69bf9 | |||
| 5a235776b6 | |||
| 8c0a51dc90 | |||
| b2bce9c58b | |||
| c9597f7adc | |||
| abd135a220 | |||
| dd623356f4 | |||
| 3ffba83723 | |||
| a0b5e91227 | |||
| a178d68b60 | |||
| 2375df5df3 | |||
| 4962fa57cb | |||
| ad67c486e8 | |||
| 20135734b3 | |||
| ac4edc6e0a | |||
| 2fad124bf8 | |||
| 908cf7ccca | |||
| dd3ea056b0 | |||
| 7f665bf25b | |||
| 38fbbde191 | |||
| 7f0720f956 | |||
| cc1e895474 | |||
| 3f9448f619 | |||
| 82a856b61e | |||
| ec9f26400a | |||
| 4ea3672b1e | |||
| 65bca804bb | |||
| ed3a2c9c8b | |||
| 8212b0e550 | |||
| bd7abf4e35 | |||
| 285ef64160 | |||
| 069972aff0 | |||
| 68d6b5fd6f | |||
| 6ae5ed905f | |||
| 0b4a1682c8 | |||
| 2c963b7230 | |||
| 2b165cd3a4 | |||
| db7833609d | |||
| 37ab629d70 | |||
| 5d4aefc99c | |||
| 6c2d238e20 | |||
| 2a7d6facce | |||
| 7618cafb6c | |||
| 09666cc719 | |||
| 6733519ae5 | |||
| 87f23d9712 | |||
| 3688fa19e6 | |||
| 19cc74f21a | |||
| f1db002b87 | |||
| ca0cf19a1f | |||
| 81d4cd8644 | |||
| b0100e738a | |||
| b2a7fc525d | |||
| 3a1ee28729 | |||
| c18566bcf2 | |||
| 0345726a79 | |||
| 6f0b408833 | |||
| 48c9323d4a | |||
| 7636573ebb | |||
| a3a7bd2170 | |||
| 29d4e51c7b | |||
| d716dec160 | |||
| 99347d3f00 | |||
| 488649ce99 | |||
| 02228b2717 | |||
| fb20997dc9 | |||
| 463748e395 | |||
| bd82036d5f | |||
| f13950e453 | |||
| 219611c821 | |||
| a09fa0a4d6 | |||
| 3e988ede88 | |||
| e7f56622c1 | |||
| 1998ee3e20 | |||
| 027cc7393d | |||
| 7b81cb7f72 | |||
| 0143591858 | |||
| e8ace39418 | |||
| 9907a97cb0 | |||
| 84e04b6552 | |||
| 417a47bdb5 | |||
| cfe9f817a3 | |||
| 9409488008 | |||
| 2858e42e80 | |||
| d6db0ff40d | |||
| 0d4b2aea2d | |||
| 8199553734 | |||
| 7900df3dce | |||
| 76db822df3 | |||
| 64dc1215fd | |||
| a94b502ce6 | |||
| bf4b9c6c09 | |||
| 91bbdbf154 | |||
| 8c095b9a56 | |||
| 6c82de55c6 | |||
| 6f4c7c3bf1 | |||
| 6affbd57ea | |||
| 6fc423f608 | |||
| 83aa76c33d | |||
| d779daf6af | |||
| 42c120aa83 | |||
| 19920b13da | |||
| a99070005f | |||
| f40409a688 | |||
| f2e0861709 | |||
| 4f931e4b7e | |||
| d16ae4e267 | |||
| 74ba889dea | |||
| 5776021970 | |||
| a11786cbea | |||
| aea2c74fdf | |||
| 8b147a32a2 | |||
| cc143b52cf | |||
| ad79238c84 | |||
| 986c6c0ce4 | |||
| 484028e721 | |||
| 1b11e89f29 | |||
| 5cdb3da8c0 | |||
| feadcf5740 | |||
| d6c5a11ae6 | |||
| f3827c71c9 | |||
| 1875426e4b | |||
| 780720165a | |||
| b0156c685e | |||
| f2090fe1b5 | |||
| 92aae28f07 | |||
| 4614080e24 | |||
| 8e30ec5be9 | |||
| 30548da71c | |||
| 914495a9f3 | |||
| fa364ce139 | |||
| f2ab9cc879 | |||
| cb820bb666 | |||
| c6650c156b | |||
| 193221773d | |||
| 06cd8b844c | |||
| cfbc99581b | |||
| ab41362a5c | |||
| b27035b3a6 | |||
| 9f0c3fd9fa | |||
| a71663a2a2 | |||
| 8257a0333e | |||
| 577a0db862 | |||
| 2ef7b93761 | |||
| b177636d7a | |||
| 282a0e70ad | |||
| 3619152b9f | |||
| ee68cc3f72 | |||
| 7200023bcc | |||
| 058d8d529f | |||
| aa07497cba | |||
| f1353dda86 | |||
| 3679e59eed | |||
| f59c34a544 | |||
| 20a3eefb1e | |||
| 22d5d52c14 | |||
| 93edb1b74d | |||
| ddfd41b06d | |||
| 7c5eb062e3 | |||
| b75f8b1704 | |||
| 6c784e3db7 | |||
| f4919ba97c | |||
| 8b3e3b3b45 | |||
| a8a8147a61 | |||
| f82dac1906 | |||
| 4b6f9077bf | |||
| 21903710e7 | |||
| f4b974eec1 | |||
| fb68bdb699 | |||
| 8ae0c2f3d3 | |||
| 753d070535 | |||
| db7b1db947 | |||
| 6b5c737b04 | |||
| 1605f542cc | |||
| 5417222f44 | |||
| aaeb86b132 | |||
| bc22792a36 | |||
| 462850afb7 | |||
| d1ae43d0d3 | |||
| f57a6ae5ff | |||
| d3ac52758c | |||
| 7da52b65b7 | |||
| 7b6dfba654 | |||
| 2b1efb5e50 | |||
| a390fe16e2 | |||
| c94c4d43c4 | |||
| df45cb4a37 | |||
| fd941c249e | |||
| 45058d57a5 | |||
| f2a422b02f | |||
| a5f843cbfb | |||
| c3c603f169 | |||
| a95e510015 | |||
| 47d74032e6 | |||
| 540a1188aa | |||
| 90331b622c | |||
| ad30adca2f | |||
| 4d4f443c27 | |||
| c7483615cf | |||
| d13144a9d7 | |||
| 155c9d619f | |||
| 51b0a8f55f | |||
| e50ec493cb | |||
| 312e072070 | |||
| fab600d3b0 | |||
| 1ea6f60ea2 | |||
| bf7b44c2d7 | |||
| ff16c69d59 | |||
| c037d9960d | |||
| fccf75324b | |||
| 094f2ffee4 | |||
| eff17d177c | |||
| 222b01f488 | |||
| 711793ffdc | |||
| 338bc20a0a | |||
| ab3ff1612c | |||
| 870b744e9b | |||
| b621d692d0 | |||
| f5a58d884b | |||
| d05f38b661 | |||
| 64da630258 | |||
| 1d8a4a6442 | |||
| d9ea8a81f1 | |||
| b9fb0dd720 | |||
| 7ec0b3594c | |||
| 693a78db7d | |||
| b8b7b69ba0 | |||
| ce5d8ae7c2 | |||
| a212e145ac | |||
| 9130994736 | |||
| c79aaaf9eb | |||
| fa9eb0c0b4 | |||
| af9f185d32 | |||
| ac3f04f6ac | |||
| dcbec978ee | |||
| a37c304f26 | |||
| 7cfb2e727d | |||
| 3faa272db9 | |||
| e1ff05c605 | |||
| bb7aa6209f | |||
| 3ef56be2dd | |||
| e913dab5db | |||
| aac79579ab | |||
| 9871d4772d | |||
| 53881c6220 | |||
| 756296886b | |||
| b0f5941e46 | |||
| 38dd7678f2 | |||
| 10406a3541 | |||
| e245237c2b | |||
| e23f816bfb | |||
| d44fe1d6b5 | |||
| 59420d8568 | |||
| 94dbe9f1af | |||
| 935f002dbf | |||
| 25f07f89dd | |||
| a756e47682 | |||
| 15f188e4f9 | |||
| 2faf243189 | |||
| f4b065b9f6 | |||
| dc2df628e3 | |||
| 7755745f2f | |||
| b2d8dd70ed | |||
| 58d97ad4e8 | |||
| b5ea6bb461 | |||
| 7c9add3d73 | |||
| 896d81e286 | |||
| 25cbe5cd3e | |||
| 13583322b5 | |||
| f3e1de5f37 | |||
| 94fdc18c3c | |||
| f4a6cb1db2 | |||
| dc9424d3bd | |||
| afec56d03b | |||
| f56798aeb9 | |||
| 13df92fbd8 | |||
| 95f8ba918d | |||
| 0bc13b5292 | |||
| a1a38b5538 | |||
| 1742e38c10 | |||
| 7b2d04605e | |||
| 07b83561d1 | |||
| f920b4cbf5 | |||
| 44ca7c8623 | |||
| 7c1ea12331 | |||
| 7171892984 | |||
| 88d38bb900 | |||
| 3ff073d1ea | |||
| 8e2066b4bd | |||
| 9735ac3b7c | |||
| 77c5731b7b | |||
| e04b1c9199 | |||
| 75e3dc2794 | |||
| f1e26fed4f | |||
| ca34a2d65d | |||
| e2ac5d117a | |||
| 6c5218913b | |||
| da8463534b | |||
| 9958f80026 | |||
| 5744aad028 | |||
| 87d575dce4 | |||
| 1c30611b1e | |||
| 1cb14d22bf | |||
| 55bca95ad2 | |||
| 5fe96b6677 | |||
| 2c0daee481 | |||
| 62394f5b85 | |||
| 55f2889c24 | |||
| 5fdd8a570a |
+23
-6
@@ -4,16 +4,16 @@
|
||||
# .NET Framework 4.8 / x86 and need MXAccess COM installed, so they build ONLY on a Windows host.
|
||||
# They are out of scope for this Linux `portable` job — the `windows-x86` job below builds and
|
||||
# tests them on windev (10.100.0.48) over SSH (see scripts/ci/run-windev-ci.sh), and the nightly
|
||||
# `nightly-windev` job additionally runs the live-MXAccess smoke, which is scheduled-only so it
|
||||
# never gates a push.
|
||||
# `nightly-windev` job additionally runs the full solution build, gateway suite, and live-MXAccess
|
||||
# smoke, which are scheduled-only so they never gate a push.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
# 06:00 UTC nightly: the `nightly-windev` job re-runs the x86 Worker build + full Worker.Tests on
|
||||
# windev, then the live-MXAccess smoke (MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1) and a full-slnx build.
|
||||
# 06:00 UTC nightly: x86 Worker build + Worker.Tests, full-slnx build, gateway tests, then the
|
||||
# live-MXAccess smoke (MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1), serialized on windev's host lock.
|
||||
# Push/PR runs skip it — the job is gated `if: github.event_name == 'schedule'`.
|
||||
- cron: '0 6 * * *'
|
||||
|
||||
@@ -23,7 +23,12 @@ jobs:
|
||||
# tests, the codegen/descriptor freshness guards, and the clients that build on Linux.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# fetch-depth: 0 (full history + tags): the CLI-46 client-version-freshness check below
|
||||
# diffs each client's source against its last publish tag (e.g. clients/go/vX.Y.Z), which
|
||||
# a shallow, tagless checkout cannot resolve.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
@@ -77,6 +82,18 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./scripts/check-codegen.ps1
|
||||
|
||||
# CLI-46: catches a client whose source changed since its last publish
|
||||
# tag while the version constant still reads that tag's already-shipped
|
||||
# number — the drift the pack-clients.ps1/tag-go-module.ps1 collision
|
||||
# guards only catch at actual publish time. Needs tag history, hence
|
||||
# `fetch-depth: 0` on this job's checkout above.
|
||||
- name: Client version freshness
|
||||
shell: pwsh
|
||||
run: ./scripts/check-client-versions.ps1
|
||||
|
||||
- name: Code-reviews README consistency
|
||||
run: python code-reviews/regen-readme.py --check
|
||||
|
||||
- name: Gateway fake-worker tests
|
||||
run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj -c Release --no-build
|
||||
|
||||
@@ -165,7 +182,7 @@ jobs:
|
||||
run: ./scripts/ci/run-windev-ci.sh test
|
||||
|
||||
nightly-windev:
|
||||
# TST-25: scheduled deep run — full Worker.Tests + live-MXAccess smoke + full-slnx build on windev.
|
||||
# TST-35: scheduled deep run — Worker, full-slnx, gateway, and live-smoke tiers on windev.
|
||||
# Gated to the schedule event via `if:` (not an unsatisfiable `runs-on`), so push/PR runs mark it
|
||||
# skipped, not queued. Never gates a push. On failure it opens a Gitea issue so a red nightly is
|
||||
# visible even though nobody watches the Actions page.
|
||||
@@ -181,7 +198,7 @@ jobs:
|
||||
PUBLIC_SERVER_URL: https://gitea.dohertylan.com
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Full Worker.Tests + live-MXAccess smoke on windev
|
||||
- name: Worker + gateway tests + live-MXAccess smoke on windev
|
||||
env:
|
||||
# WINDEV_SSH_KEY is stored base64-encoded (single line) so Gitea's line-oriented secret
|
||||
# masker redacts it in the step env echo; run-windev-ci.sh decodes it. A raw multiline PEM
|
||||
|
||||
@@ -155,3 +155,4 @@ generated-scratch/
|
||||
|
||||
# Agent worktrees (subagent isolation) — never commit
|
||||
.claude/worktrees/
|
||||
.worktrees/
|
||||
|
||||
@@ -49,7 +49,18 @@ mxaccessgw changes here — remote/push status, the `.proto` contracts (`mxacces
|
||||
`mxaccess_worker.proto`, `galaxy_repository.proto`), the two-process architecture, shared-lib
|
||||
consumption, or per-project commands — update the **MxAccessGateway entry in
|
||||
[`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md)** in the same change so the umbrella index
|
||||
never drifts from this repo. (Mirrors the same rule in the peer repos.)
|
||||
never drifts from this repo. (Mirrors the same rule in the peer repos.) **Keep the index row to
|
||||
a few lines** — since 2026-09-03 the umbrella keeps only short summary rows and parks long
|
||||
per-project narrative in `../scadaproj/.claude/skills/sister-project-history/<project>.md`
|
||||
(create `mxaccessgateway.md` there if this gateway ever needs one) and shared-library history
|
||||
in `../scadaproj/<lib>/CLAUDE.md`.
|
||||
|
||||
> ⛔ **Wonder box remote access (2026-09-11): no `psbridge`, no `winrm`.** Both skills are retired/disabled —
|
||||
> the WinRM remote-exec channel set off the security software on the wonder boxes (vd03/vq03/vq04). Do not
|
||||
> run remote commands (service control, publish/swap, PowerShell) on those boxes and do not stand up a
|
||||
> replacement channel until the user clears one. Still allowed: UNC/SMB file copies and logging directly
|
||||
> into the SQL Servers. Deploy steps that need a command on the box are blocked; see the umbrella
|
||||
> [`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md) for the current hold.
|
||||
|
||||
## Build, Test, Run
|
||||
|
||||
@@ -71,7 +82,7 @@ dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Test
|
||||
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
|
||||
|
||||
# API-key admin CLI (same exe, "apikey" subcommand)
|
||||
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session,invoke,event,metadata,admin
|
||||
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj -- apikey create-key --key-id dev --display-name "dev" --scopes session:open,session:close,invoke:read,invoke:write,invoke:secure,events:read,metadata:read,admin
|
||||
```
|
||||
|
||||
Single test by name (xUnit `--filter`):
|
||||
@@ -115,11 +126,18 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
|
||||
- **One worker per session** (invariant). Multi-subscriber event fan-out and reconnect-with-replay have shipped and are config-gated: `AllowMultipleEventSubscribers` (default `false`) enables fan-out up to `MaxEventSubscribersPerSession` (default `8`); `DetachGraceSeconds` (default `30`) retains a session after its last subscriber drops so clients can reconnect; `ReplayBufferCapacity` / `ReplayRetentionSeconds` control how much event history the replay ring keeps. Default config is single-subscriber (`AllowMultipleEventSubscribers` off), but detach-grace and replay retention are **on** by default (`DetachGraceSeconds=30`, `ReplayBufferCapacity=1024`, `ReplayRetentionSeconds=300`): a detached session is retained for 30 s and recent events are buffered for reconnect. The reconnect protocol is consumable end-to-end: a resuming `StreamEvents` (via `after_worker_sequence`) that predates the retained ring gets a `ReplayGap` sentinel, and all five official clients surface it as a typed signal. Orphan-worker reattach after a gateway restart is **deferred, not planned** — see `docs/DesignDecisions.md` (Session-Resilience Epic Scope, session-resilience epic Phase 5); the invariant on the next line stands. See `docs/DesignDecisions.md` and `docs/Sessions.md`.
|
||||
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
|
||||
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
|
||||
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
|
||||
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, `AuthenticateUser` credentials, and `AcknowledgeAlarm` `operator_password` must never reach logs. Value logging is opt-in and redacted.
|
||||
- **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/zb_mom_ww_mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
|
||||
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
|
||||
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
|
||||
|
||||
## Backlog / open work
|
||||
|
||||
- **Open work lives in Gitea issues** on `gitea.dohertylan.com/dohertj2/mxaccessgw` (`tea issues list --repo dohertj2/mxaccessgw`). The 2026-11-06 migration filed the open markdown items as issues labelled `migrated` (#169–#213).
|
||||
- **Markdown backlogs are historical records with `→ #N` pointers**, not status registers: the archreview trackers (`archreview/remediation/00-tracking.md`, `archreview/2026-07-12/remediation/00-tracking.md` + `90-candidate-findings-next-cycle.md`, `archreview/2026-08-19/remediation/00-tracking.md`, `archreview/2026-10-21/remediation/00-tracking.md`), `stillpending.md`, `docs/plans/2026-06-14-deferred-followups.md`, and `code-reviews/*/findings.md`. Do not update status there.
|
||||
- **File new follow-ups, deferrals and review findings as issues**, using the family labels `type:{bug,feature,followup,review-finding,deploy-gate,debt,docs,test,infra}`, `priority:p0`..`p3`, `blocked`, `cross-repo`.
|
||||
- Reference issues from plans and docs as `#N` (cross-repo: `lmxopcua#N`, `ScadaBridge#N`).
|
||||
|
||||
## Source Update Workflow
|
||||
|
||||
When source code changes, build and test the affected component before reporting work done. If the change crosses component boundaries, build each affected component — don't rely on a single top-level build:
|
||||
@@ -155,11 +173,11 @@ External analysis sources referenced by design docs:
|
||||
|
||||
## Authentication
|
||||
|
||||
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`.
|
||||
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`.
|
||||
|
||||
Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `event` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `event`-scoped key cannot attach to another key's retained session.
|
||||
Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `events:read` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `events:read`-scoped key cannot attach to another key's retained session.
|
||||
|
||||
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
|
||||
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`; `/hubs/alarms` and the `/alarms` page additionally require a **real authenticated dashboard role** (`MxGateway.Dashboard.Alarms`, a requirement type the environment-bypass handler does not handle), because the alarm feed is gateway-central with no per-session provenance and so cannot be tag-scoped — its metadata stays galaxy-wide for authenticated Viewers, but anonymous localhost and `Authentication:Mode=Disabled` do not receive it at all. Dashboard event visibility is **tag-scoped per session** (`IDashboardSessionAcl`, gating both the events hub and the session-details page's in-process subscribe): an Administrator sees every session, while any other caller sees a session only when its tags — inherited from the owning API key's `apikey --dashboard-tags`, never from the client's request — intersect the tags their LDAP groups grant via `Dashboard:GroupToTag`. That grant is **re-resolved from the caller's LDAP group claims against current options on every subscribe** (not read from the tag claims stamped at login, which would outlive a config-revoked grant for the cookie's 8-hour sliding life); login-stamped tag claims are the fallback only for a principal carrying no group claims — i.e. the hub-token principal, whose grant the mint already re-resolved. Untagged sessions follow `Dashboard:UntaggedSessionVisibility` (default `AdminOnly`), and a principal with neither claim kind (anonymous localhost included) is an empty-grant Viewer. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD, session Close/Kill, or the alarm feed (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
|
||||
|
||||
## Process / Platform Notes
|
||||
|
||||
|
||||
+9
-2
@@ -79,6 +79,12 @@ Add one entry per finding to the `## Findings` section of the module's
|
||||
|
||||
- **Finding ID** — `<Module>-NNN`, numbered sequentially within the module and
|
||||
never reused (e.g. `Worker-001`). IDs are permanent even after resolution.
|
||||
The `GWC`/`WRK`/`IPC`/`SEC`/`CLI`/`TST` prefixes are reserved for the
|
||||
`archreview/` domain registers (a separate, whole-repo review series with its
|
||||
own tracking documents) — never assign one of these prefixes to a
|
||||
`code-reviews/` finding, and any other work stream (perf passes, ad hoc
|
||||
remediation plans, etc.) that wants a stable per-item ID must mint its own
|
||||
distinct prefix rather than borrowing one of these six.
|
||||
- **Severity:**
|
||||
- **Critical** — data loss, security breach, crash/deadlock, or outage.
|
||||
- **High** — incorrect behaviour with significant impact; no safe workaround.
|
||||
@@ -127,8 +133,9 @@ finding carries an unrecognised Status value. The PowerShell wrapper
|
||||
`scripts/check-code-reviews-readme.ps1` runs that check and is the intended hook
|
||||
for CI or a pre-commit step.
|
||||
|
||||
> The repo's installed `python` is the real interpreter; the bare `python3`
|
||||
> alias resolves to the Windows Store stub and fails. Use `python`.
|
||||
> On Windows, the installed interpreter may be available only as `python`
|
||||
> because `python3` can resolve to the Windows Store stub. macOS and Linux
|
||||
> normally use `python3`; CI uses the configured `python` executable.
|
||||
|
||||
The per-module `findings.md` files are the source of truth; `README.md` is the
|
||||
aggregated index and must always agree with them — which the script guarantees.
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
| TST-10 | Not started | **Confirmed open** | No `docs/Deployment.md` (`ls docs/` — no deploy doc); no deploy script under `scripts/` | Deploy knowledge still lives only in operator memory notes. |
|
||||
| TST-14 | Not started | **Confirmed open** | Repo root still holds `MxAccessGateway-docs-{issues,fixed,final}.md`, `MxGatewayClient-docs-{issues,fixed}.md`, `oldtasks.md`, `stillpending.md` (root `ls`) | `oldtasks.md` did gain a legitimate new role as the epic-governance record (TST-04), so "delete when epic closes" needs re-deciding; the five untracked `*-docs-*.md` files remain pure clutter. |
|
||||
| TST-15 | Not started (design done) | **Confirmed open** | `Server/Dashboard/Hubs/EventsHub.cs:42` — `TODO(per-session-acl)` still present | Design now exists: `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`. Mitigation shipped meanwhile: SEC-25 value redaction in the hub mirror (see TST-27). |
|
||||
| TST-16 | Not started | **Partially resolved — see TST-27** | `Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16,29` — `ShowTagValues` now gates value redaction of the SignalR mirror (SEC-25) | The flag is no longer fully dead, but it still does not gate `/browse` live-value display, and the config doc still calls it "Reserved" — fresh drift (TST-27). |
|
||||
| TST-16 | Not started | **Partially resolved — see TST-27** | `Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16,29` — `ShowTagValues` now gates value redaction of the SignalR mirror (SEC-25) | The flag is no longer fully dead, but it still does not gate `/browse` live-value display, and the config doc still calls it "Reserved" — fresh drift (TST-27). **Closed 2026-08-17** (branch `feat/followup-closeout`): both residuals shipped — `AlarmsHubPublisher` redacts `current_value`/`limit_value` from a deep clone on both value-bearing `AlarmFeedMessage` arms before the `/hubs/alarms` broadcast, and `DashboardLiveDataService` substitutes `[redacted]` for `/browse` value text at the service boundary (the recommended seam), so the flag now gates every dashboard value surface. Source never mutated on either hub, so gRPC subscribers are unaffected. Tests: `AlarmsHubPublisherTests`, two `DashboardLiveDataServiceTests` cases. TST-16 → `Done` in [../remediation/00-tracking.md](../remediation/00-tracking.md#change-log). |
|
||||
| TST-17 | Not started | **Confirmed open** | `Worker/MxAccess/WnWrapAlarmConsumer.cs:~264-275` — `_ = ackOperatorDomain; _ = ackOperatorFullName;` then 6-arg call; no diagnostic surfaced in the ack reply | Silent drop unchanged (comment block explains the −55 stub, but callers still can't see the degrade). |
|
||||
| TST-18 | Not started | **Confirmed open** | No `*HostedService*` test files under `Tests/Gateway/Sessions/` or `Tests/Gateway/Workers/` | Sweep cores remain covered indirectly (`SessionManagerTests.cs:985,1015` now also cover faulted-reap). |
|
||||
| TST-19 | Not started | **Confirmed open** | `Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs` — no lockstep note / `WorkerPipeSession` pointer (grep empty); no README in `Fakes/` | Risk is higher now that the real-worker suite has no scheduled run (TST-25). |
|
||||
@@ -53,7 +53,7 @@
|
||||
### TST-27 — `ShowTagValues` config doc still says "Reserved" after SEC-25 made the flag live — **Medium** · documentation currency
|
||||
|
||||
- **Files:** `docs/GatewayConfiguration.md:185` ("Reserved display control for tag values"); `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16-29` (flag read and enforced); `docs/GatewayDashboardDesign.md:170` (current, documents the redaction)
|
||||
- **Description:** SEC-25 wired `Dashboard:ShowTagValues` into `DashboardEventBroadcaster`: when `false` (default), tag values are blanked from the deep-cloned event before it is mirrored to SignalR. The flag is therefore no longer dead — but the authoritative config reference still labels it "Reserved", so an operator consulting the options table concludes toggling it does nothing, when it actually controls whether tag values leak to every dashboard hub subscriber. This is remediation-created drift: the dashboard design doc was updated in the same change, the config doc was not. (Residual of TST-16 remains separately: the flag still gates nothing in the `/browse` live-value path.)
|
||||
- **Description:** SEC-25 wired `Dashboard:ShowTagValues` into `DashboardEventBroadcaster`: when `false` (default), tag values are blanked from the deep-cloned event before it is mirrored to SignalR. The flag is therefore no longer dead — but the authoritative config reference still labels it "Reserved", so an operator consulting the options table concludes toggling it does nothing, when it actually controls whether tag values leak to every dashboard hub subscriber. This is remediation-created drift: the dashboard design doc was updated in the same change, the config doc was not. (Residual of TST-16 remains separately: the flag still gates nothing in the `/browse` live-value path.) **Closed 2026-08-17** — the config row was rewritten again when TST-16's residuals shipped: it now states the three seams the flag covers (events-hub mirror, alarms hub, `/browse`), so neither the "Reserved" wording nor the `/browse` carve-out survives. See the TST-16 row above.
|
||||
- **Recommendation:** Update the `:185` row to describe the mirror-redaction behavior (and its security relevance given the missing per-session ACL, TST-15); note the `/browse` gap or close it when TST-16 is decided.
|
||||
|
||||
### TST-28 — Gateway-side `max_frame_bytes` handshake field has no test in the portable suite — **Low** · test coverage
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# MxAccessGateway — Remediation Tracking (2026-07-12 review)
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on gitea.dohertylan.com/dohertj2/mxaccessgw
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status here; file new work as an issue.
|
||||
>
|
||||
> Rows still open in this cycle's tracker were re-triaged and carried into the [2026-10-21 tracker](../../2026-10-21/remediation/00-tracking.md) (`R21-*` rows; cycle-1 rows promoted as `R21-O1`..`R21-O34`), whose `→ #N` pointers name the Gitea issues.
|
||||
|
||||
Master progress tracker for the 2026-07-12 follow-up architecture review. Generated 2026-07-13.
|
||||
|
||||
Source review: [`../00-overall.md`](../00-overall.md) · Per-domain remediation designs are linked below and hold the full **Finding / Impact / Design / Implementation / Verification** for every entry here. The first-cycle tracker ([`../../remediation/00-tracking.md`](../../remediation/00-tracking.md)) remains the record for the original 153 findings; this document tracks only the 47 new IDs (GWC-24+, WRK-21+, IPC-23+, SEC-31+, CLI-35+, TST-25+) plus the old-tracker actions listed at the end.
|
||||
@@ -79,6 +84,9 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| WRK-26 | Low | P1 | S | WRK-23 (soft); discharges IPC-29 | Done | Write-priority and overflow doc drift from the WRK-07 change |
|
||||
| WRK-27 | Low | — | S | — | Done | Alarm poll bypasses the watchdog's in-flight suppression (15 s vs 75 s) |
|
||||
| WRK-28 | Low | — | S | WRK-21 (same batch) | Done | 10,000 drain cap is a duplicated magic constant |
|
||||
| WRK-29 | — | — | — | — | Reserved (do not reuse) | Interim ID space possibly burned alongside WRK-31 by `e913dab`'s ad hoc numbering; not confirmed in the repo or its history, held back defensively |
|
||||
| WRK-30 | — | — | — | — | Reserved (do not reuse) | Interim ID space possibly burned alongside WRK-31 by `e913dab`'s ad hoc numbering; not confirmed in the repo or its history, held back defensively |
|
||||
| WRK-31 | — | — | — | — | Reserved (do not reuse) | Interim ID burned by `e913dab` (session-owned transport disposal), cited in code at `Ipc/WorkerPipeSession.cs:45,177` and `Ipc/WorkerPipeSessionTests.cs` outside the register; not a finding — registered per cycle-3 `20-worker.md` (~line 9) so it is never reassigned. Cycle-3 new findings start at WRK-32. |
|
||||
|
||||
### Contracts & IPC — [30-contracts-ipc.md](30-contracts-ipc.md)
|
||||
|
||||
@@ -92,7 +100,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| IPC-28 | Low | — | S | — | Done | docs/Grpc.md missing CommandTooLarge → ResourceExhausted mapping |
|
||||
| IPC-29 | Low | — | S | WRK-26 (discharged by) | Done (discharged by WRK-26) | WorkerFrameProtocol.md missing write-scheduling/sequencing section |
|
||||
| IPC-30 | Low | P0 | M | WRK-21 (same batch) | Done | Oversized event frame: keep session-fatal, make the death structured |
|
||||
| IPC-31 | Info | — | — | — | N/A | Gateway creation-time sequence stamping accepted; diagnostic-only, decision recorded |
|
||||
| IPC-31 | Info | — | — | — | Done (superseded by GWC-28) | Gateway creation-time sequence stamping — resolved when GWC-28 moved gateway stamping to write time; the two sides now implement the same semantics |
|
||||
| IPC-32 | Info | — | S | IPC-25 (folded in) | Done | check-codegen banner relabel 1/4…4/4 |
|
||||
|
||||
### Security & dashboard — [40-security-dashboard.md](40-security-dashboard.md)
|
||||
@@ -152,6 +160,14 @@ Sequence these together rather than piecemeal — several are one change set spa
|
||||
- ~~When CLI-38 lands, close old **CLI-08** with a pointer here.~~ Done 2026-08-07: CLI-38 landed and old CLI-08 is now `Done` in the first-cycle tracker, pointing at [CLI-38](50-clients.md#cli-38--align-netgojava-on-hresult--0-lands-prior-cli-08-cures-the-doc-drift---medium--p1).
|
||||
- ~~When WRK-26 lands, its doc section also discharges the WorkerFrameProtocol gap~~ Done 2026-08-07: WRK-26 landed and **IPC-29** is discharged, pointing at [WRK-26](20-worker.md#wrk-26--write-priority-and-overflow-doc-drift-from-the-wrk-07-change---low--p1). ~~When TST-25 lands, revisit old **TST-05** (scheduled live smoke) and **TST-24** (client wire tests), which it unlocks.~~ TST-05 revisited 2026-08-10: `Partially done` in the first-cycle tracker — the `nightly-windev` job closes the scheduled-cadence half, but the finding's coverage-audit half stays open (the live suite reaches all six late-added COM commands and none of the five control commands). TST-24 revisited in the same change and closed `Done`: Go/Rust/Java already had real-server wire tests, and the two genuine gaps (.NET, Python) now have them plus a CI step.
|
||||
|
||||
## 2026-08-25 documentation correction
|
||||
|
||||
The 2026-08-07 IPC-23 change-log entry's `docs/Grpc.md` deviation rationale is inaccurate:
|
||||
`DrainEvents` is not private worker IPC. It is a public `MxCommandKind` accepted by the
|
||||
`Invoke` RPC. Cycle-3 IPC-38 adds the missing public count-cap, byte-cap,
|
||||
drain-until-empty, and destructive-stream interaction contract to `docs/Grpc.md` while
|
||||
preserving the historical entry below.
|
||||
|
||||
## Change log
|
||||
|
||||
| Date | Change |
|
||||
|
||||
@@ -24,6 +24,7 @@ members, no positional records). The worker builds and tests only on the Windows
|
||||
| WRK-26 | Low | P1 | S | WRK-23 (soft — sequence prose); discharges IPC-29 | Done | Write-priority and overflow doc drift from the WRK-07 change |
|
||||
| WRK-27 | Low | — | S | — | Done | Alarm poll bypasses the watchdog's in-flight suppression (15 s vs 75 s) |
|
||||
| WRK-28 | Low | — | S | WRK-21 (land in the same commit cluster) | Done | 10,000 drain cap is a duplicated magic constant with a comment-only sync contract |
|
||||
| WRK-31 | — | — | — | — | Reserved (do not reuse) | Not a finding. `e913dab` (session-owned transport disposal) coined "WRK-31" ad hoc in code comments (`Ipc/WorkerPipeSession.cs:45,177`) and in `Ipc/WorkerPipeSessionTests.cs`, before the register assigned it. WRK-29/WRK-30 appear nowhere in the repo or its history but may have been burned by the same interim numbering, so they are held back defensively too — see the tracker's WRK-29/30/31 rows. Registered here per cycle-3 `archreview/2026-08-19/20-worker.md` (~line 9, "Finding-ID note"); that review's new findings start at **WRK-32** to guarantee no collision. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -231,6 +231,8 @@ The published-side collection (`CollectPublishedSymbols`, `:61-79`) grows matchi
|
||||
|
||||
**Disposition: N/A — no action.** `gateway.md:328-330` already declares sequence a per-sender diagnostic aid, never validated; both validators ignore it; the divergence is harmless and now explicitly documented by the IPC-29 doc section (which states the two sides' stamping points). If sequence ever becomes load-bearing, stamp in the gateway write loop (`WorkerClient.cs:390-397`) — recorded here so the next editor finds the decision.
|
||||
|
||||
**Resolution: Done — superseded by GWC-28 (2026-08-20, per cycle-3 verification).** GWC-28 moved the gateway-side stamp from creation to the point of writing: `WriteLoopAsync` now stamps `Sequence` immediately before `_writer.WriteAsync` (`Server/Workers/WorkerClient.cs:465-471`), and `CreateEnvelope` leaves it unset (`:1229-1236`); `gateway.md:425-431` was rewritten to "both sides stamp at the point of writing", closing the divergence this finding recorded. Regression test `WorkerClientTests.ConcurrentInvokesEmitStrictlyIncreasingSequencesOnTheWire` covers it. Per `archreview/2026-08-19/30-contracts-ipc.md` (IPC-31 row), tracker flipped from `N/A` to `Done (superseded by GWC-28)`. See cycle-3 **IPC-39** for the one interaction the change created with the IPC-03 pre-check.
|
||||
|
||||
---
|
||||
|
||||
## IPC-32 — `check-codegen.ps1` check labels are miscounted `Info` · `—` (rides IPC-25)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Candidate Findings for the Next Review Cycle (surfaced during 2026-07-12 remediation)
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on gitea.dohertylan.com/dohertj2/mxaccessgw
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status here; file new work as an issue.
|
||||
>
|
||||
> Rows still open in this cycle's tracker were re-triaged and carried into the [2026-10-21 tracker](../../2026-10-21/remediation/00-tracking.md) (`R21-*` rows; cycle-1 rows promoted as `R21-O1`..`R21-O34`), whose `→ #N` pointers name the Gitea issues.
|
||||
|
||||
These were discovered while remediating the 2026-07-12 backlog but were **out of scope** for it — each is either pre-existing, by-design residual, or a new observation. They are recorded here so the next review cycle can triage them. None blocks the 2026-07-12 cycle, which is complete. Rows struck through have since been fixed ahead of that cycle; the original finding text is kept so the triage record stays readable.
|
||||
|
||||
| ID (proposed) | Area | Severity (est.) | Summary |
|
||||
@@ -11,7 +16,7 @@ These were discovered while remediating the 2026-07-12 backlog but were **out of
|
||||
| ~~NEXT-05~~ | Worker frame writer | Info | **Resolved 2026-08-10 as a documented decision** — the lazy `DequeueNext` purge stays: any subsequent write drains both queues to empty and the heartbeat loop bounds tombstone residency to one interval, while eager `Queue<T>` rebuilds under `_gate` would add ordering-invariant surface next to the WRK-22 interlock for no real gain. Rationale recorded in `docs/WorkerFrameProtocol.md`. Original finding: A batch whose remaining frames are tombstoned by cancellation leaves dead `PendingFrame` entries in `_eventFrames`/`_controlFrames` until a future `DequeueNext` pops and skips them. Same pre-existing behavior as single-frame WRK-22, amplified per-batch; in practice heartbeats purge them promptly, so not a real leak. |
|
||||
| ~~NEXT-06~~ | Testing / live LDAP | Medium | **Resolved 2026-08-07** — fixtures realigned to the shared directory (`admin`/`password` for the GwAdmin success path, `gw-viewer`/`password` for the bind-succeeds-but-no-role path); verified `Failed: 0, Passed: 5` live against the shared GLAuth at `10.100.0.35:3893`, so the success-path assertion (GwAdmin group claim + Admin role claim) now fails if the service-account credential is wrong. Original finding: `DashboardLdapLiveTests` fixtures have drifted from the shared GLAuth directory, leaving the suite with **no positive-proof coverage of the service-account bind**. Its only success-path test, `AuthenticateAsync_AdminInGwAdminGroup_Succeeds`, binds `admin`/`admin123`, but the directory's `admin` user carries the standard dev password (`scadaproj/infra/glauth/config.toml`), so that assertion cannot pass. `AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails` binds fixture user `readonly`, which **does not exist** in the GLAuth config at all — it passes for the wrong reason (user-not-found rather than the group-missing branch it names; the `readonly` name is in fact barred by the README's user/group case-collision rule). The three remaining tests are negative assertions that pass whether or not the service account can bind. Net effect: a green `DashboardLdapLiveTests` run proves nothing about the bind credential — surfaced during SEC-36, where the suite was considered as a substitute for the deferred dashboard-login check and rejected. Fix: realign the fixtures to real directory users (e.g. `multi-role`/`gw-viewer`) or add the missing users to the GLAuth config, and add one test that fails when the service-account credential is wrong. |
|
||||
| ~~NEXT-07~~ | Deployment / windev | High | **Resolved 2026-08-07** — a fresh portable framework-dependent publish of `origin/main` (`a346d51`) was built in a clean clone at `C:\build\mxgw-redeploy`, deployed to `C:\publish\mxaccessgw\Server-20260807`, and the `MxAccessGw` NSSM service repointed at it; the service now holds a stable PID with both `5120`/`5130` listening, a worker spawned, the Galaxy snapshot restored (129 objects / 56,731 attributes) and a clean event log. Root cause confirmed as the version skew this row predicted: the deployed 2026-06-25 build carried `ZB.MOM.WW.Auth.ApiKeys` 0.1.2.0, which supports auth-DB schema 2, against a `gateway-auth.db` stamped at schema 3 on 2026-07-15 by an ephemeral run of newer code — schema 3 is the current shared-lib version (`SqliteAuthSchema.CurrentVersion=3` in Auth 0.1.5), so the redeploy is the forward fix and the DB was left alone. Rollback artifacts kept: `C:\ProgramData\MxGateway\gateway-auth.db.bak-next07` (with `-wal`/`-shm`) and the previous `C:\publish\mxaccessgw\Server` directory. Two side effects worth recording: the old deploy's `appsettings.json` held the LDAP bind password in **plaintext on disk**, while the new one keeps the repo's `${secret:ldap/mxgateway/bind}` token with the NSSM environment supplying the value, so no plaintext LDAP secret remains on that host; and the redeploy tripped the SEC-06 `Ldap:Transport=None` production hard-stop (`GatewayOptionsValidator.cs:178`), resolved by relabelling the host — windev runs `Dashboard:DisableLogin=true`, which this repo's own docs mark dev/test-only, so its `Production` label contradicted its configuration and `DOTNET_ENVIRONMENT` was changed to `Staging` (that one NSSM environment entry only; the other nine preserved byte-identical). SEC-06 is untouched for genuinely production hosts — see NEXT-08 for the posture problem that relabelling defers. Original finding: The `10.100.0.48` (windev) gateway deployment is **stale and crash-looping**, and has been since at least 2026-08-06 (~10k Hosting-failed events/day). The deployed Server binary dates to 2026-06-25 and predates the auth-DB migration of 2026-07-15: it opens a schema-version-3 `gateway-auth.db` that it supports only at version 2 and aborts at startup, so the `MxAccessGw` service never reaches a listening state. Not a code defect in the current tree — a deploy-drift/operations gap — but it means the repo's only deployed host has been dark for over a day and any host-level verification (including SEC-36's dashboard-login check) is blocked until it is repaired. Fix: deploy a current Server build to windev, or restore/downgrade the auth DB to schema 2 if the old binary must stand. Worth asking separately why a service in a permanent restart loop raised no alert. Discovered during SEC-36. |
|
||||
| NEXT-08 | Security / LDAP posture | Medium | **The shared GLAuth offers no TLS, so SEC-06 makes it undeployable from a `Production`-labelled host.** `GatewayOptionsValidator` (`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:178`) refuses to start when `Ldap:Transport=None` in the `Production` environment, and `docs/GatewayConfiguration.md`'s `Transport` row states "Deployed hosts must set `Ldaps` or `StartTls`" — but the shared instance at `10.100.0.35:3893` has `[ldaps] enabled=false`, port `3894` closed, and answers StartTLS with `protocolError`, so neither value can work against it. That instruction is currently unsatisfiable for every host that authenticates there. windev sidestepped it on 2026-08-07 by moving to the `Staging` environment name (NEXT-07), which is honest for a dev/test rig but is not available to a real production host. Resolution needs either LDAPS/StartTLS on the shared GLAuth (certificate plus a trust story on each gateway host) or an explicit written posture decision that production gateways bind a different, TLS-capable directory. Surfaced during the NEXT-07 redeploy. |
|
||||
| NEXT-08 → #180 | Security / LDAP posture | Medium | **The shared GLAuth offers no TLS, so SEC-06 makes it undeployable from a `Production`-labelled host.** `GatewayOptionsValidator` (`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:178`) refuses to start when `Ldap:Transport=None` in the `Production` environment, and `docs/GatewayConfiguration.md`'s `Transport` row states "Deployed hosts must set `Ldaps` or `StartTls`" — but the shared instance at `10.100.0.35:3893` has `[ldaps] enabled=false`, port `3894` closed, and answers StartTLS with `protocolError`, so neither value can work against it. That instruction is currently unsatisfiable for every host that authenticates there. windev sidestepped it on 2026-08-07 by moving to the `Staging` environment name (NEXT-07), which is honest for a dev/test rig but is not available to a real production host. Resolution needs either LDAPS/StartTLS on the shared GLAuth (certificate plus a trust story on each gateway host) or an explicit written posture decision that production gateways bind a different, TLS-capable directory. Surfaced during the NEXT-07 redeploy. |
|
||||
| ~~NEXT-09~~ | Build / versioning | Low | **Resolved 2026-08-10** — the Exec path now appends `.` so the trailing backslash can no longer escape the closing quote, and `SourceRevisionId` is additionally gated on a short-SHA regex so no future git failure text can be stamped either. macOS stamp verified unchanged; Windows stamp verified on windev with this batch. Original finding: **Windows builds stamp git's error text into `InformationalVersion`.** `src/Directory.Build.props:29` runs `git -C "$(MSBuildThisFileDirectory)" …`; MSBuild's directory property ends in a backslash, which escapes the closing quote, so the command is malformed on Windows. The target carries `ContinueOnError`, so the failure is silent and git's stderr is captured as the revision — an observed stamp reads `0.1.2+fatal: cannot change to …`. Any Windows build without a preset `SourceRevisionId` therefore ships a binary that cannot be correlated back to a commit, defeating the point of TST-11. Not reproducible on macOS/Linux, where the separator is `/`. Fix sketch: append `.` to the path or trim the trailing separator before quoting. Surfaced while identifying the deployed binary during NEXT-07. |
|
||||
| NEXT-10 | Docs / glauth | Medium | **`glauth.md`'s "Pre-provisioned users" table contradicts both the directory and the rest of its own file.** It documents `readonly`/`readonly123` and `admin`/`admin123`, neither of which matches `scadaproj/infra/glauth/config.toml` (`readonly` does not exist there; `admin` carries the standard dev password), and lists the `ReadOnly` gid as `5501` against an actual `5601`. Its dashboard section, by contrast, is correct — so the file is internally inconsistent and a reader cannot tell which half to trust. This table was the **root cause of the NEXT-06 fixture drift**, and it has propagated further: `docs/GatewayTesting.md`'s `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` default and the matching literal in `WorkerLiveMxAccessSmokeTests` both take `admin123` from it. Deliberately **not** fixed in the 2026-08-07 pass: the table is entangled with the OPC-UA group taxonomy (gids, role mapping, and the sister-repo consumers of the same directory), so reconciling it means sweeping that taxonomy as one unit rather than patching two rows. |
|
||||
|
||||
@@ -21,5 +26,5 @@ These are **live-infrastructure actions the operator must execute** — the repo
|
||||
|
||||
- ~~**SEC-36** — rotate the dev LDAP service-account credential per `docs/runbooks/SEC-36-ldap-credential-rotation.md` (generate new secret in `scadaproj/infra/glauth`, pre-stage the NSSM env var on deployed hosts, rotate GLAuth on `10.100.0.35`, verify dashboard login). The committed literal is gone from the working tree but remains recoverable from git history until rotation completes — **rotation is the load-bearing half.**~~ **Executed 2026-08-07**: the `serviceaccount` `passsha256` was replaced in `scadaproj/infra/glauth/config.toml` (commit `aada53b`) and the shared GLAuth recreated on `10.100.0.35`, so the literal recoverable from this repo's history no longer binds `dc=zb,dc=local`. The new value lives only in the GLAuth hash, windev's NSSM environment, and dev user-secrets. `wonder-app-vd03` was out of scope (it binds a different, `dc=scadalink`/`dc=scadabridge` directory). **Caveat, since closed:** windev's dashboard `/login` verification was deferred while that host's gateway was crash-looping on the unrelated stale-deployment fault filed as NEXT-07, so the bind was verified directly by `ldapsearch` as `cn=serviceaccount,dc=zb,dc=local` instead. After the 2026-08-07 redeploy the real check ran on windev (login as `multi-role` → 302 + dashboard cookie, anonymous control → `/login`), so the rotated credential is now proven through the `DashboardAuthenticator` path itself; see `docs/runbooks/SEC-36-ldap-credential-rotation.md` Correction 3. SEC-36 is fully `Done`.
|
||||
- ~~**TST-30** — register a second Gitea `act_runner` on `10.100.0.35` per `docs/runbooks/TST-30-second-ci-runner.md` to relieve the single-shared-runner bottleneck.~~ **Executed 2026-08-07**: `gitea-runner-2` (id 5, capacity 2) is online on `10.100.0.35` via the `/opt/gitea` compose stack, same `container.network: traefik`, token from a `0600` file mount; the existing runner (id 1, capacity 4) was untouched. Concurrency verified — jobs from three runs ran simultaneously across both runners, and a `gitea-runner-2` job cloned successfully from `http://gitea:3000`. TST-30 is now fully `Done`.
|
||||
- **TST-30 follow-up — reset the Gitea instance runner registration token.** Runner-1's compose block was moved to the same `0600` file-mount pattern as runner-2 on 2026-08-07 (compose and both backups now `0600 root:root`, runner-1 recreated with its identity intact), but hygiene alone does not retire the token: both runners share **one instance-scope registration token** that was world-readable for roughly five months and is still live — a probe registered runner id 6 with it, then deleted it. Gitea 1.26.4 exposes no rotation via CLI or API (both paths are get-or-create and hand back the same value), so the reset must be done in the admin web UI ("Reset registration token"). Afterwards, refresh `/opt/gitea/runner_token` on `10.100.0.35` and shred the two token-bearing compose backups — they are the last copies of the old value. See `docs/runbooks/TST-30-second-ci-runner.md`.
|
||||
- **TST-30 follow-up — reset the Gitea instance runner registration token.** → #181 Runner-1's compose block was moved to the same `0600` file-mount pattern as runner-2 on 2026-08-07 (compose and both backups now `0600 root:root`, runner-1 recreated with its identity intact), but hygiene alone does not retire the token: both runners share **one instance-scope registration token** that was world-readable for roughly five months and is still live — a probe registered runner id 6 with it, then deleted it. Gitea 1.26.4 exposes no rotation via CLI or API (both paths are get-or-create and hand back the same value), so the reset must be done in the admin web UI ("Reset registration token"). Afterwards, refresh `/opt/gitea/runner_token` on `10.100.0.35` and shred the two token-bearing compose backups — they are the last copies of the old value. See `docs/runbooks/TST-30-second-ci-runner.md`.
|
||||
- **TST-25 follow-ups** — old **TST-05** (scheduled live-MXAccess smoke) is now covered by the `nightly-windev` job; old **TST-24** (client wire tests in CI) is unblocked by the working Windows tier.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# MxAccessGateway — Architecture Review, Cycle 3
|
||||
|
||||
Date: 2026-08-19. Branch: `main` at `8b3e3b3` (clean; ~288 commits since the cycle-2 baseline `4f5371f`). Working tree: macOS, **static review only** — no builds, no test runs, no source changes, no remote hosts (one sanctioned read-only script, `regen-readme.py --check`, was executed by the testing reviewer).
|
||||
|
||||
## Scope and method
|
||||
|
||||
Third run of the review series ([cycle 1](../00-overall.md) 2026-07-08, [cycle 2](../2026-07-12/00-overall.md) 2026-07-12). Same shape: six domain reviews, each with three passes — (1) verify every cycle-2 finding's tracked status (`../2026-07-12/remediation/00-tracking.md`, plus the NEXT-xx candidate rows in `90-candidate-findings-next-cycle.md`) against code read at HEAD, (2) deep-review the remediation code itself and everything else landed in the period, (3) a fresh sweep. Every claim in the domain reports carries a `file:line` citation read at HEAD.
|
||||
|
||||
| Report | Domain |
|
||||
|---|---|
|
||||
| [10-gateway-core.md](10-gateway-core.md) | Gateway server: sessions, worker lifecycle, IPC client, gRPC streaming, alarm monitor, GalaxyRepository package adoption |
|
||||
| [20-worker.md](20-worker.md) | Worker process: STA pump, COM lifetime, pipe session, event queue, write-completion correlation, subtag failover |
|
||||
| [30-contracts-ipc.md](30-contracts-ipc.md) | Protos, frame protocol (both sides), codegen/descriptor pipeline, launch-environment contract |
|
||||
| [40-security-dashboard.md](40-security-dashboard.md) | API-key auth + failure limiter, LDAP dashboard, tag ACL, secrets store, SignalR hubs, redaction |
|
||||
| [50-clients.md](50-clients.md) | .NET, Go, Java, Python, Rust clients; publish pipeline; cross-client parity |
|
||||
| [60-testing-docs-gaps.md](60-testing-docs-gaps.md) | Test architecture, CI (incl. the windev tier), documentation currency, repo hygiene |
|
||||
|
||||
The period under review is the largest yet: the full cycle-2 remediation train (47 findings), the SEC-25 tag-ACL epic, the secrets-store adoption, the alarm truncation / `snapshot_status` rollout, the OnWriteComplete correlation feature, a worker perf wave, the 0.2.0/0.2.1 client publish, the windev CI tier, and the fca978d marker-sweep restoration.
|
||||
|
||||
## Overall verdict
|
||||
|
||||
**The cycle-2 remediation held completely.** All 47 cycle-2 findings tracked `Done` were verified present, sound, and tested at HEAD — **zero false `Done` claims, zero regressions in previously verified fixes, across all six domains.** Four gateway/IPC items carry a caveat in the precise sense of cycle 2 ("fix is correct for the filed defect; a residual failure mode one layer out survives"), each filed as a new Low (GWC-32/34/35, IPC-33). The worker, security, clients, and testing domains reported no caveats at all.
|
||||
|
||||
The new top line is **0 Critical, 0 High, 4 Medium, 25 Low, 5 Info** across 34 new findings — down from 0/1/14/28/4 (47) in cycle 2 and 1/12/54/39 in cycle 1. For the first time in the series, **none of the new findings is a correctness defect in the core data path that a client can trigger in default configuration.** The four Mediums are: one deployment-posture gap outside this repo's control (SEC-37, GLAuth has no TLS), one operator action outstanding from a tracked runbook (TST-32, runner-token reset), one version-hygiene repeat (CLI-46, constants equal to already-published artifacts — for the second cycle running), and one stale fixture doc with live-test fallout (TST-31, `glauth.md` credential table).
|
||||
|
||||
The residual risk profile is now dominated by **edges of new machinery and process residue**, not product defects: the recency-based write-completion correlation (WRK-32), the fail-open browse scope under a missing ambient identity (GWC-38), the un-tag-scoped alarms hub (SEC-40), the undeclared launch-env-var contract (IPC-36), and a client family that keeps shipping post-publish changes under the published version number (CLI-46).
|
||||
|
||||
## Prior-finding verification
|
||||
|
||||
| Domain | Cycle-2 Done claims | Verified | Caveats (filed as new) | Cycle-1 leftovers |
|
||||
|---|:-:|:-:|---|---|
|
||||
| Gateway core | 7 (GWC-24..30) | 7/7 | GWC-24→**GWC-32**/33, GWC-26→**GWC-35**, GWC-27→**GWC-34** | 13 re-confirmed open, none regressed; GWC-16 recommended Won't-fix (documented deferral) |
|
||||
| Worker | 8 (WRK-21..28) + NEXT-04/05 | 8/8 + 2/2 | none — WRK-21 fix exceeded its design (enqueue-time sizing) | WRK-01/04/06 re-verified intact |
|
||||
| Contracts & IPC | 8 (IPC-23..32) | 8/8 | IPC-23→**IPC-33** | IPC-08 substantially resolved by `7c1ea12` (recommend closing; residual = IPC-34); IPC-10/11/12 open; IPC-31 superseded by GWC-28 (flip to Resolved) |
|
||||
| Security & dashboard | 6 (SEC-31..36) | 6/6 | none; SEC-36 includes the executed operator rotation | SEC-19/21/26 resolved in the period; SEC-13 impact reduced; SEC-24 narrowed; 13/14/15(accepted)/16/17/18/23/27 open |
|
||||
| Clients | 11 (CLI-35..45) + NEXT-02 | 11/11 + 1/1 | none — including the halves prior cycles skipped (CLI-21 tag guard, CLI-02/42 docs, CLI-26 drift test) | CLI-05/08 partly, CLI-09/13/17/32 divergence table updated |
|
||||
| Testing, docs & gaps | 6 (TST-25..30) | 6/6 | TST-30 carries one tracked operator residual (→ **TST-32**) | TST-06 materially narrowed (tracker stale); TST-07/09/14 partially addressed; TST-17 row needs refresh; TST-05/10/18–22 open |
|
||||
|
||||
**Tracker housekeeping recommended:** close GWC-16 (Won't-fix, recorded), IPC-08 (resolved, residual → IPC-34), IPC-31 (superseded by GWC-28); refresh TST-06 and TST-17 rows; note that `e913dab` burned **WRK-31** (and possibly 29/30) as interim IDs and the 2026-08-15 perf pass stamped unregistered **GWC-3x** labels into code comments (GWC-31) — the register must stay the sole ID authority.
|
||||
|
||||
## Severity roll-up (new findings only)
|
||||
|
||||
| Domain | Critical | High | Medium | Low | Info | IDs |
|
||||
|---|:-:|:-:|:-:|:-:|:-:|---|
|
||||
| Gateway core | — | — | — | 6 | 2 | GWC-31..38 |
|
||||
| Worker | — | — | — | 3 | — | WRK-32..34 |
|
||||
| Contracts & IPC | — | — | — | 5 | 2 | IPC-33..39 |
|
||||
| Security & dashboard | — | — | 1 | 3 | 1 | SEC-37..41 |
|
||||
| Clients | — | — | 1 | 4 | — | CLI-46..50 |
|
||||
| Testing, docs & gaps | — | — | 2 | 4 | — | TST-31..36 |
|
||||
| **Total** | **0** | **0** | **4** | **25** | **5** | **34** |
|
||||
|
||||
(Cycle 2: 0 Critical, 1 High, 14 Medium, 28 Low, 4 Info. Cycle 1: 1 Critical, 12 High, 54 Medium, 39 Low.)
|
||||
|
||||
## Headline findings
|
||||
|
||||
### Medium
|
||||
|
||||
- **SEC-37 — the Production plaintext-LDAP hard-stop is unsatisfiable.** The shared GLAuth (`10.100.0.35:3893`) offers no TLS, so every host in the family that authenticates against it cannot run with `DOTNET_ENVIRONMENT=Production` — which is why the deployed service runs as `Staging`. Promotion of NEXT-08; the fix is family-wide (GLAuth TLS in `scadaproj/infra/glauth/`), not in this repo. ([40](40-security-dashboard.md))
|
||||
- **CLI-46 — version constants again equal the already-published 0.2.0/0.2.1** while post-publish changes shipped — including the fix for the published-broken Python `connect()` found by TST-24. Same shape as cycle-2's CLI-39; the publish-pipeline guards (registry-collision hard-stop) will now *block* the next publish rather than ship a duplicate, but the bump discipline still isn't happening at change time. ([50](50-clients.md))
|
||||
- **TST-31 — `glauth.md`'s user table contradicts the directory**, and the wrong credential propagated into the live-LDAP test surface (promotes NEXT-10). ([60](60-testing-docs-gaps.md))
|
||||
- **TST-32 — the Gitea runner registration token (world-readable ~5 months, proven live) still awaits its web-UI reset** — the one operator residual of the otherwise-complete TST-30. ([60](60-testing-docs-gaps.md))
|
||||
|
||||
### Low — items worth a second look
|
||||
|
||||
- **WRK-32 — write-completion correlation is recency-based per handle**: a late `OnWriteComplete` from an earlier write can be reported as the current write's outcome. Parity-clean otherwise (no synthesized events), but the docs should state the attribution rule. ([20](20-worker.md))
|
||||
- **GWC-38 — `GatewayBrowseScopeProvider` fails open** to the full Galaxy hierarchy when the ambient identity is absent, resting a scoping control on an undeclared interceptor-ordering invariant. ([10](10-gateway-core.md))
|
||||
- **SEC-40 / SEC-41 — tag ACL edges**: the session-less alarms hub broadcasts galaxy-wide alarm metadata to every Viewer including empty-grant and anonymous-localhost principals; revoked `GroupToTag` grants survive on cookie principals for up to the 8 h sliding life. ([40](40-security-dashboard.md))
|
||||
- **SEC-38 — limiter aggregate sidestep**: pre-filling the per-peer key-id cap (SEC-32) routes later failures to the fallback partition, which doesn't feed the cross-peer aggregate, restoring the `limit × addresses` guessing bound. ([40](40-security-dashboard.md))
|
||||
- **IPC-33 — DrainEvents byte budget is sized against the pipe max, not gRPC `MaxSendMessageSize`**; with a legal larger `Worker:MaxMessageBytes` the reply crosses the pipe and fails at the gRPC send with events already dequeued — the cycle-2 "no event loss" invariant violated one boundary later. ([30](30-contracts-ipc.md), [20](20-worker.md))
|
||||
- **IPC-34 — `WorkerCancel` fires only on the gateway's own timeout**; a client deadline/disconnect leaves the command on the STA queue (the IPC-08 residual). ([30](30-contracts-ipc.md))
|
||||
- **IPC-36 — six launch env-vars form an undeclared second gateway↔worker contract**, four new since baseline, duplicated as literals on both sides, two documented. ([30](30-contracts-ipc.md))
|
||||
- **GWC-37 — the subtag watch-list is resolved once per monitor lifecycle**; a Galaxy-Repository outage at startup empties the failover's coverage for the session's life. ([10](10-gateway-core.md))
|
||||
- **GWC-32 / GWC-34 — dispose races**: `WorkerClient.DisposeAsync` never enters a terminal state (the GWC-24 overflow fault can misfire); `EnsureDistributorCreated` recreates a pump-less distributor after teardown and a racing attach hangs silently. ([10](10-gateway-core.md))
|
||||
- **CLI-47/48/50 — alarm-feed CLI divergence**: `snapshot_status` rendering differs per language, the new renderer arms are untested in three CLIs (Go has no `stream-alarms` test at all), and the Python CLI's default `--max-messages 1` returns only the provider-status preamble so the README example fetches no alarm data. ([50](50-clients.md))
|
||||
- **TST-35 — the Windows-host run of the *gateway* (net10) suite is still manual-only**, and its failure class recurred three times in August; TST-34 — the code-reviews README check is wired into no CI job. ([60](60-testing-docs-gaps.md))
|
||||
|
||||
## Cross-cutting themes
|
||||
|
||||
1. **"One layer out" is now the only residual pattern in the core.** Every caveated fix this cycle (GWC-24/26/27 → GWC-32/34/35, IPC-23 → IPC-33) is the cycle-2 pattern: the invariant holds at the boundary that was filed and leaks at the next one (pipe max vs gRPC max; shutdown state vs dispose state; unacked→acked vs acked→unacked). The fix shape remains the same advice as last cycle: specify against the invariant, not the symptom — but the blast radius has shrunk from "session-fatal" to "misdiagnosed fault / silent hang in a race".
|
||||
2. **The tag-ACL epic is sound at its two designed seams and leaky at the one it didn't name.** Events hub and session-details subscribe are pinned with decision-table tests and live-LDAP coverage; the alarms hub (SEC-40) was never in scope, the cookie lifetime (SEC-41) wasn't either, and the browse scope (GWC-38) fails open. A one-page "which surfaces are tag-scoped and what principal each trusts" table would close all three or document them as accepted.
|
||||
3. **Version bumps are still not part of the change, for the second cycle** (CLI-39 → CLI-46). The pipeline guards now make this a blocked publish instead of a duplicate publish, which is the right failure — but the process fix is a "did any `clients/**` source change since the last publish tag" check at PR time, not at pack time.
|
||||
4. **Second contracts exist outside the proto.** The launch env-vars (IPC-36) and the GalaxyRepository wire-identity (IPC-37, still unguarded though now testable) are both gateway↔peer agreements with no schema, no test, and partial docs. The codegen guard is now excellent for everything *in* the protos; these are what's left.
|
||||
5. **ID discipline slipped in the period.** Interim WRK-31 and unregistered GWC-3x labels in code comments (GWC-31), plus stale tracker rows (TST-06/17, IPC-08/31, GWC-16) — cheap to fix, and the register is the only thing that makes cycle-over-cycle verification possible.
|
||||
6. **Documentation currency is high and the one big wound healed.** The fca978d sweep's collateral damage was systematically restored (816 lines judged, 17 restorations), every currency spot-check matched code, and the remaining doc findings (TST-31/33/36, GWC-36, IPC-38) are inherited debris rather than new drift.
|
||||
|
||||
## Prioritized roadmap
|
||||
|
||||
**P0 — small, do now**
|
||||
1. CLI-46: bump all five client versions past 0.2.0/0.2.1 before anything else touches `clients/`; add the PR-time "source changed since publish tag" check.
|
||||
2. TST-32: reset the Gitea runner registration token (web UI); TST-31: fix `glauth.md` and the propagated live-test credential.
|
||||
3. Tracker housekeeping: close GWC-16/IPC-08/IPC-31, refresh TST-06/TST-17, register WRK-31 and retire the GWC-3x comment labels (GWC-31).
|
||||
|
||||
**P1 — hardening, each a contained change**
|
||||
4. Tag-ACL completeness: SEC-40 (scope the alarms hub or document it as galaxy-wide-by-design), SEC-41 (re-resolve tag claims on restart / shorten validity), GWC-38 (fail closed when identity is absent and assert interceptor order).
|
||||
5. IPC-33 + IPC-39: budget DrainEvents against `min(pipe max, gRPC send max)`; fold the sequence-stamp window into the pre-check.
|
||||
6. IPC-34: forward `WorkerCancel` on caller cancellation, not only gateway timeout; IPC-35: surface handshake `WorkerFault`s structurally.
|
||||
7. WRK-32: document (or tighten) the recency-based completion attribution; WRK-33: give the discarded command-task observer its fault-observing continuation.
|
||||
8. GWC-32/34: terminal-state on dispose; refuse `EnsureDistributorCreated` after disposal. GWC-37: re-resolve the subtag watch-list on a timer or on Galaxy-Repository recovery.
|
||||
9. SEC-37: family-wide — add TLS to the shared GLAuth (`scadaproj/infra/glauth/`), then move the deployed service to `Production`.
|
||||
|
||||
**P2 — the rest**
|
||||
10. Client CLI parity batch (CLI-47/48/49/50), IPC-36 launch-env contract (single shared constants class + doc table), IPC-37 wire-identity test, TST-34/35 CI wiring, doc debris (TST-33/36, GWC-36, IPC-38, SEC-39 comment), alongside the unchanged cycle-1 backlog.
|
||||
|
||||
## What is demonstrably good
|
||||
|
||||
Everything cycle 2 asked to preserve is intact: STA pump and COM teardown discipline, write-time sequence stamping (now peek-stamp-commit on the worker, GWC-28 on the gateway), negotiated frame maxima with startup cross-validation, additive-only proto evolution with every generated tree regenerated in the same commit as the proto change, fail-closed auth with constant-time compare, owner-scoped `StreamEvents`, and the layered fake-worker test architecture.
|
||||
|
||||
New this period and worth protecting under change:
|
||||
|
||||
- **The windev CI tier** — per-push x86 worker build + Worker.Tests and a nightly live-MXAccess run, hardened by its own acceptance checks (base64-stored key, `MXGW_CI_LOCK_HELD` handoff, SHA shape validation, fail-red on unreachable host), and the full-suite testhost hang root-caused to a zero-buffer pipe and structurally fixed (windev baseline = macOS = 879).
|
||||
- **Byte-budgeted DrainEvents with enqueue-time sizing** — the cycle-2 headline defect fixed beyond its design; the IPC-33 residual is a configuration edge, not the default path.
|
||||
- **The failure limiter's composite partition + aggregate + probe admission** — the cycle-2 lockout-DoS is closed; SEC-38/39 are edge refinements.
|
||||
- **Fixtures-first client conformance** — ten shared command-reply fixtures consumed by all five suites, so HRESULT sign, category-verdict, scrub, and malformed-reply rules are pinned in bytes; plus real-wire tests in all five clients (which immediately found the published-broken Python `connect()`).
|
||||
- **Three-seam value redaction** (events mirror, alarms hub, `/browse`), clone-only, with the config doc telling the truth about all three.
|
||||
- **The codegen guard** — descriptor set, `Contracts/Generated`, Rust vendored protos in both directions, Go/Python bindings with pinned generators, Java aggregates via the java job's diff gate, and Check 4 now Windows-runnable.
|
||||
- **Per-correlation `CommandTooLarge` → `ResourceExhausted`** remains the model for fault isolation the residual IPC-33/34/35 items should copy.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Gateway Server Core — Cycle-3 Review
|
||||
|
||||
- **Date:** 2026-08-19
|
||||
- **Commit:** `8b3e3b3` (`main`, clean)
|
||||
- **Baseline:** `4f5371f` (2026-07-12 cycle-2 review; ~288 commits since, ~99 of them touching `src/ZB.MOM.WW.MxGateway.Server`)
|
||||
- **Method:** static review on macOS; every cited file read at HEAD. No build, no test, no source change.
|
||||
- **Scope:** gateway server process — `Sessions/`, `Workers/`, `Grpc/` (`MxAccessGatewayService`, `EventStreamService`, mapper, validator), `Alarms/` (monitor, watch-list resolver, subtag-fallback gateway half), the session/alarm-feed plumbing the dashboard consumes (`DashboardLiveDataService` session use, `AlarmsHubPublisher` feed consumption), and the `ZB.MOM.WW.GalaxyRepository` package adoption (`AddZbGalaxyRepository`/`MapZbGalaxyRepository` wiring, `GatewayBrowseScopeProvider`, `DashboardGalaxySummaryProjector`). Excludes `Security/` internals and dashboard UI (security-dashboard reviewer), except the browse-scope provider assigned here.
|
||||
- **Prior reports:** `archreview/2026-07-12/10-gateway-core.md`; remediation status source `archreview/2026-07-12/remediation/00-tracking.md` (+ `10-gateway-core.md`); first-cycle backlog `archreview/remediation/00-tracking.md`.
|
||||
|
||||
## Prior-finding verification
|
||||
|
||||
### Cycle-2 findings (GWC-24..30, all tracked Done)
|
||||
|
||||
Every Done claim was verified against code read at HEAD. **All 7 hold; 3 are verified with caveats** — in each case the fix is present and sound for the reported defect, but a residual failure mode one layer out survives and is filed as a new finding.
|
||||
|
||||
| ID | Claimed | Verified? | Evidence (HEAD) | Notes |
|
||||
|----|---------|-----------|-----------------|-------|
|
||||
| GWC-24 | Done | ✅ holds, **with caveat** | `Workers/WorkerClient.cs:107-119` (staging channel bounded at `checked(2 * EventChannelCapacity)`, Wait+TryWrite idiom documented), `:646-676` (`StageWorkerEvent` faults `ProtocolViolation` on a rejected `TryWrite`, message names the bound, the depth, and the actionable fix), `:653-658`/`:350-359` (single `_eventQueueDepth` counter incremented at staging, decremented at consumer read — total undelivered depth), `:120-121` + `Metrics/GatewayMetrics.cs:524` (pull-model gauge registration), `:44-48` (field comment states bound + fault + gauge coverage); startup overflow guard `Configuration/GatewayOptionsValidator.cs:343-357` (`QueueCapacity ≤ int.MaxValue / 2` so the 2× derivation cannot overflow); regression tests `Tests/Gateway/Workers/WorkerClientTests.cs:781` (`StagingChannelOverflowFaultsWorkerWithoutWaitingForFullModeTimeout`), `:865` (`WorkerEventQueueDepthGaugeCountsStagedEvents`) | Implemented exactly per the remediation design, including the shutdown-drop guard (`IsTerminalState()`), and the design's extra validator guard. Caveat: the shutdown-drop guard does not cover `DisposeAsync`, which completes the channels **without** entering a terminal state — a dispose-race `TryWrite` failure is then misdiagnosed as the backpressure fault → **GWC-32**. A faulted-but-retained client also keeps contributing stale depth to the gauge until disposal → **GWC-33** (Info). |
|
||||
| GWC-25 | Done | ✅ holds | `Sessions/SessionEventDistributor.cs:519-535` (empty-ring branch: `oldestAvailableSequence = gap ? _highestSequenceSeen + 1 : 0`, with a full explanatory comment citing the finding), out-param contract updated `:430-440`; emitted verbatim `Grpc/EventStreamService.cs:139-145`, `:227-239`; proto comment amended `Contracts/Protos/mxaccess_gateway.proto:797-798` ("When nothing is retained … `highest observed + 1` … resume formula remains valid"); `docs/Sessions.md:262` (dedicated empty-ring paragraph); tests `Tests/Gateway/Sessions/SessionEventDistributorTests.cs:935` (`ResumeUsingSentinelFormulaAfterEmptyRingGapDeliversLiveEvents`) and `Tests/Gateway/GatewayEndToEndReconnectReplayTests.cs:246` (`ReconnectAfterFullAgeEvictionResumesWithSentinelFormula`) | Sound end to end. The `afterSequence == ulong.MaxValue` wrap guards in both branches (`:521`, `:540`) are preserved, so the old dead-stream input is also inert. Proto + docs + server + tests all landed as one story; the client halves (CLI-35/36, NEXT-02) are closed in the tracking record. |
|
||||
| GWC-26 | Done | ✅ holds, **with caveat** | `Alarms/GatewayAlarmMonitor.cs:246-258` (internal lease attached **before** `SubscribeAlarmsAsync` + first `ReconcileAsync`, with the window rationale in the comment), `:272-281` (drain the same lease after reconcile; order-safety noted), `ApplyReconcile` acked-delta repair `:674-684` (both-present, `existing.CurrentState != incoming.CurrentState && incoming == ActiveAcked` → `Acknowledge` feed transition); `ISessionManager.ReadAlarmEventsAsync` removed (grep: absent from `Sessions/ISessionManager.cs` and `SessionManager.cs`); repair-scope + at-least-once contract documented `:607-633`; regression tests `Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs` | Implemented per design, including the method-removal half and the "never synthesize MxEvents" boundary note (`:610-614`: repairs are `AlarmFeedMessage`s, never `MxEvent`s). Caveat: the state-delta repair is one-directional — only unacked→acked is repaired; an acked alarm silently replaced by a new **unacked** instance is absorbed without a feed transition → **GWC-35**. |
|
||||
| GWC-27 | Done | ✅ holds, **with caveat** | `Sessions/GatewaySession.cs:590-617` (`AttachInternalEventSubscriber` readiness gate under `_syncRoot` before `EnsureDistributorCreated`, same exception code/message shape as `AttachEventSubscriber:955-961`; the poison mechanism is spelled out in the comment); test `Tests/Gateway/Sessions/GatewaySessionTests.cs:683` (`AttachInternalEventSubscriberBeforeReadyThrowsAndDoesNotPoisonDistributor`) | The premature-attach poison path is closed. Caveat: the disposal-side twin remains — `EnsureDistributorCreated` recreates a never-started distributor after `DisposeAsync` nulls `_eventDistributor`, silently hanging a racing attach → **GWC-34**. |
|
||||
| GWC-28 | Done | ✅ holds | `Workers/WorkerClient.cs:463-472` (`WriteLoopAsync` stamps `envelope.Sequence = unchecked(++_nextSequence)` immediately before `_writer.WriteAsync`, single-consumer rationale in comment), `:49-51` (field is a plain `ulong` touched only by the write loop, no interlock), `CreateEnvelope:1225-1241` (sequence deliberately unset, comment cites GWC-28/WRK-04); test `Tests/Gateway/Workers/WorkerClientTests.cs:228` (`ConcurrentInvokesEmitStrictlyIncreasingSequencesOnTheWire`) | Exactly the WRK-04-mirror design. All outbound envelopes flow through `_outboundEnvelopes` (hello `:171`, commands `:251`, cancel `:849/:858`, shutdown `:383`), so coverage is total. Note the `InvokeAsync` size pre-check comment (`:236-241`) correctly explains why the computed size cannot be reused as the writer's `knownSize` post-GWC-28 — the interaction was thought through. |
|
||||
| GWC-29 | Done | ✅ holds | `Grpc/MxAccessGrpcMapper.cs:43-55` (new `MapCommand(MxCommand)` overload; the one required clone kept with the aliasing rationale), `:27-33` (request overload delegates); `Grpc/MxAccessGatewayService.cs:131-136` (`mapper.MapCommand(commandToInvoke)` — the request clone and `invokeRequest` local are gone; `TrackCommandReply` still reads `commandToInvoke` at `:147`, which is why the inner clone must stay and does) | Sound. `WorkerClient.CreateCommandEnvelope`'s no-aliasing invariant comment (`:1194-1198`) remains accurate. |
|
||||
| GWC-30 | Done | ✅ holds | `Workers/WorkerFrameReader.cs:22-24` (per-reader `_lengthPrefix` scratch buffer), `:46` (used per frame), `:11-16` (class remark documents the non-reentrancy contract) | As designed. (Unrelatedly, the *label* "GWC-30" has since been reused by an off-register perf pass for a different change — see **GWC-31**.) |
|
||||
|
||||
### Cycle-1 findings still open at cycle 2 — re-checked at HEAD
|
||||
|
||||
All were re-read at HEAD. **None regressed; none silently fixed; two materially changed shape** (GWC-16 is now an explicitly documented deferral; GWC-20's client-side half improved).
|
||||
|
||||
| ID | Cycle-2 status | HEAD status | Evidence |
|
||||
|----|----------------|-------------|----------|
|
||||
| GWC-05 | Open | **Still open** | `Sessions/SessionWorkerClientFactory.cs:175-185` — pipe created with explicit 128 KiB buffers (the zero-quota fix, `da84635`) but still no ACL and no `PipeOptions.CurrentUserOnly`. The local pipe-squatting exposure and the `gateway.md` pipe-security contract gap are unchanged. |
|
||||
| GWC-09 | Open | **Still open** | `Workers/WorkerProcessStartedProbe.cs:5-19` (no-op `HasExited` probe throwing `WorkerProcessLaunchException`); `Workers/WorkerProcessLauncher.cs:331-339` (`ShouldRetryStartupProbe` still excludes `WorkerProcessLaunchException`); `Configuration/WorkerOptions.cs:19-22` (both dead retry options remain). The retry pipeline still can never retry. |
|
||||
| GWC-10 | Open | **Still open** | `Workers/WorkerEnvelopeValidator.cs:15-39` — version/session/body only; no inbound sequence tracking anywhere in `WorkerClient`. (The outbound half was fixed by GWC-28; enforcement remains unimplemented on both sides, consistent with IPC-31's recorded decision.) |
|
||||
| GWC-11 | Open | **Still open** | `Sessions/GatewaySession.cs:23` (`_workerClient` not volatile); lock-free reads at `:308` (`WorkerProcessId`), `:313` (`WorkerClient`), `:1579` (`CloseAsync`), `:1732`, `:1765`, `:1877` (`DisposeAsync`). |
|
||||
| GWC-12 | Open | **Still open** | `Workers/WorkerClient.cs:419-426` — plain `if (_disposed) return; _disposed = true;`, no interlock. (Now compounds with new GWC-32: dispose also never enters a terminal state.) |
|
||||
| GWC-13 | Open | **Still open** | `Sessions/GatewaySession.cs:1956-2024` — 25 ms poll loop, routed through the injected `TimeProvider` (unchanged since cycle 2). |
|
||||
| GWC-16 | Open | **Still open — now a recorded deferral; recommend closing as Won't-fix** | `Grpc/MxAccessGatewayService.cs:111-115` — a `PERF(followup)` comment now documents the double session lookup as measured-negligible and deliberately not collapsed (the fix would duplicate `InvokeAsync`'s fault mapping at a second entry point). The rationale is sound; the tracker row should be flipped to `Won't fix` citing this comment rather than left `Not started`. |
|
||||
| GWC-17 | Open | **Still open** | `Sessions/SparseArrayExpander.cs:306` (`RpcException` factory below the gRPC layer), `:75` (GWC-03's cap rejection still flows through the same leak); invocation comment at `Sessions/GatewaySession.cs:1103-1111` still documents the layering leak explicitly. |
|
||||
| GWC-18 | Open | **Still open** | `Sessions/GatewaySession.cs:14` — no `IAsyncDisposable`; `DisposeAsync` at `:1787`. |
|
||||
| GWC-19 | Open | **Still open** | `Sessions/GatewaySession.cs:1730-1734` — `KillWorker(string)`; grep across `src/` + `clients/` finds zero callers (one doc-comment mention in `SessionManagerTests.cs:1433`). |
|
||||
| GWC-20 | Open | **Still open (client half improved)** | `Configuration/WorkerOptions.cs:52` — the config key is still `HeartbeatIntervalSeconds` ("interval … for worker heartbeats") while it is bound to the **check** interval at `Sessions/SessionWorkerClientFactory.cs:98`; the `WorkerClientOptions` field was renamed to `HeartbeatCheckInterval` (`Workers/WorkerClientOptions.cs:40`), which fixes the client-side naming but leaves the config-key conflation. The heartbeat loop still uses raw `Task.Delay` without `TimeProvider` (`Workers/WorkerClient.cs:534`). |
|
||||
| GWC-21 | Open | **Still open** | `Sessions/SessionWorkerClientFactory.cs:95-101` populates 4 of 6 `WorkerClientOptions`; `EventChannelFullModeTimeout` / `HeartbeatStuckCeiling` remain fixed defaults (`Workers/WorkerClientOptions.cs:13`, `:23`). Post-GWC-24 the staging bound is *derived* from `EventChannelCapacity` (which is configurable), so the exposure gap is narrower than at cycle 2 but the two knobs are still hard-coded. |
|
||||
| GWC-22 | Open | **Still open** | `Grpc/EventStreamService.cs:217` — `metrics.StreamDisconnected("Detached")` hardcoded in the `finally`; overflow/fault exits are not differentiated. |
|
||||
| GWC-23 | N/A | Unchanged | Decision stands; multi-subscriber mode remains config-gated and the validator comment survives. |
|
||||
|
||||
### NEXT rows touching this domain
|
||||
|
||||
- **NEXT-01** (macOS `sun_path` pipe names) — **verified resolved**: `Sessions/SessionManager.cs:532-536` builds `mxgw-{Environment.ProcessId}-{sessionUid}` with the length-budget rationale in the comment; `CLAUDE.md`/`gateway.md` describe the same format.
|
||||
- **NEXT-03** (reconcile/live duplicate broadcasts) — **verified resolved as designed (best-effort, positive-match-only)**: `Alarms/GatewayAlarmMonitor.cs:483-489` (`IsDuplicateOfCachedState`, timestamp+state marker), `:496-507` (`IsDuplicateOfReconcileClear`, tombstone consumed on match), `:645-664` (tombstones recorded per reconcile generation, cleared at `:649`); at-least-once contract restated at `:616-625`; race-driving regressions in `Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs:159`, `:218`. The one-generation tombstone lifetime and the unset-timestamp passthrough match the recorded design; consumers must (and are documented to) treat transitions idempotently.
|
||||
- NEXT-08 / NEXT-10 (GLAuth TLS posture, glauth.md table) are security/docs-domain rows, not promoted here.
|
||||
|
||||
### Also verified in passing (other-domain fixes landing in gateway-core files)
|
||||
|
||||
- **WRK-21/WRK-28/IPC-23 gateway half** — `Grpc/MxAccessGrpcRequestValidator.cs:74-88` rejects `DrainEvents.max_events` above the now-shared `GatewayContractInfo.MaxDrainEventsPerCommand` constant, with the byte-cap division of labor documented in the comment.
|
||||
- **Zero-quota pipe fix** (`da84635`) — `SessionWorkerClientFactory.cs:15-25`, `:175-185`: explicit 128 KiB in/out buffers with the windev-wedge rationale; this was the root cause of the historical full-suite testhost hang.
|
||||
- **Dead `ISessionManager.ReadEventsAsync` chain removed** (`fa9eb0c`) and `StreamEventsAsync` now consumes the subscriber channel directly (`Grpc/EventStreamService.cs:110-204`, `935f002`) — the WaitToReadAsync/TryRead loop preserves the fault-surface semantics (`:158-204`) including the worker-fault → `MarkFaulted` + metric path.
|
||||
- **Reply ownership transfer** (`3ff073d`) — `Grpc/MxAccessGrpcMapper.cs:62-85`: single-consumer argument for moving `reply.Reply` is documented and holds (one frame → one `PendingCommand` awaiter → one mapper call; `MergeDeniedInto` splices in place on the owned instance).
|
||||
- **GalaxyRepository adoption** — `GatewayApplication.cs:151-176`: scope provider registered *before* `AddZbGalaxyRepository` so the library's `TryAddSingleton` null default cannot win (order-sensitive, correctly commented); snapshot-path default seeded pre-bind with `GalaxyRepositoryOptionsValidator` + `ValidateOnStart` (SEC-33); `MapZbGalaxyRepository` at `:351`. `A2-galaxyrepository-adoption-handoff.md`'s caveats are all marked resolved and match the code. One hardening gap filed as **GWC-38**.
|
||||
- **Exception-total shutdown drain** (`95f8ba9`/`0bc13b5`) — `Sessions/SessionManager.cs:354-427`: bounded-parallel (4) close with an exception-total body, kill fallback on `CancellationToken.None`, and the deliberate token asymmetry against `CloseExpiredLeasesAsync` documented at length; `ISessionManager.ShutdownAsync`'s doc (`ISessionManager.cs:100-110`) states the bounded overrun honestly. Sweep selection stays sequential with the TOCTOU re-check inside `TryBeginCloseIfExpired` (`GatewaySession.cs:1671-1695`) — the cycle-2 close-gate invariants are preserved under the parallelization.
|
||||
|
||||
## New findings
|
||||
|
||||
IDs continue from the register. **Note on ID allocation:** the codebase already contains comment references to "GWC-31" (and a re-used "GWC-30") coined by an off-register remediation pass — those labels are *not* register entries (see GWC-31 below, which claims the ID and records the collision). Register IDs assigned here are authoritative.
|
||||
|
||||
### GWC-31 — The 2026-08-15 perf remediation stamped unregistered "GWC-3x" IDs into code comments, colliding with this register
|
||||
|
||||
- **Severity:** Low
|
||||
- **Category:** Documentation & comments
|
||||
- **Location:** `Workers/WorkerClient.cs:44` ("GWC-30" = pull-model queue-depth gauge), `:236` ("PERF(GWC-31)"), `:253` ("GWC-31: one pooled timer…"); `Metrics/GatewayMetrics.cs:46`, `:213`, `:284`, `:463`, `:524` ("GWC-30" = gauge pull model); `Tests/Gateway/Workers/WorkerClientTests.cs:163`, `:179` ("GWC-31" = WorkerCancel forwarding); analogous worker-side citations (`Worker/Ipc/WorkerFrameReader.cs`/`WorkerFrameWriter.cs`, per `docs/plans/2026-08-15-perf-review-remediation.md:544-548`)
|
||||
- **Description:** The 2026-08-15 performance review (whose findings live only in `docs/plans/2026-08-15-*.md`, not in `archreview/`) labeled its changes with GWC-series IDs that were never added to any register. "GWC-30" now means two different things — the archreview register says *frame-reader 4-byte prefix allocation*; the code comments say *pull-model worker queue-depth gauge* — and "GWC-31" exists only in comments. Finding IDs are the repo's cross-reference currency ("cite it in commits, branches, and PRs" — remediation tracker); a reader following a code comment to the register lands on the wrong finding, and future review cycles (this one included) must dodge already-burned numbers. REVIEW-PROCESS.md's "never reused" rule is violated in spirit.
|
||||
- **Recommendation:** Sweep the perf-pass citations to a distinct prefix (e.g. `PERF-nn` keyed to `docs/plans/2026-08-15-perf-review-remediation.md` task numbers) or append the perf findings to the register under fresh, unclaimed IDs and fix the comments to match. Add a line to REVIEW-PROCESS.md stating that GWC/WRK/IPC/SEC/CLI/TST prefixes are reserved for the archreview registers.
|
||||
|
||||
### GWC-32 — `WorkerClient.DisposeAsync` never enters a terminal state, so the GWC-24 staging-overflow fault can misfire during a dispose race and the "already terminal" shutdown-drop claim is not guaranteed
|
||||
|
||||
- **Severity:** Low
|
||||
- **Category:** Concurrency & thread safety (residual of GWC-24)
|
||||
- **Location:** `Workers/WorkerClient.cs:419-434` (`DisposeAsync` sets `_disposed`, cancels, completes all three channels — but `_state` is untouched), `:661-676` (`StageWorkerEvent` fallback: `TryWrite` false → `if (IsTerminalState()) return;` → otherwise `ProtocolViolation` fault), `:632-644` (XML doc: a completed staging channel is "safely dropped because the client is already terminal")
|
||||
- **Description:** `MarkClosed` and `SetFaulted` both flip `_state` to a terminal value *before* completing the channels, so the `IsTerminalState()` guard in `StageWorkerEvent` correctly classifies a completed-channel `TryWrite` miss as shutdown. `DisposeAsync` is the third channel-completing path and does not: a client disposed while still `Ready` keeps reporting `IsTerminalState() == false`. If the read loop is inside `DispatchEnvelope` when `DisposeAsync` completes `_eventStaging` (the `_stopCts.Cancel()` only interrupts the *next* `ReadAsync`), the in-flight `StageWorkerEvent` takes the overflow arm: a spurious `ProtocolViolation` session fault with the misleading "consumer is draining slower than the worker produces" message, a `worker-event-staging` overflow metric, a redundant `KillOwnedProcess`, and a `WorkerStopped("ProtocolViolation")` metric — all during an intentional teardown. Reachability is narrow: in the normal teardown order (`CloseAsync`/`Kill` → `DisposeAsync`) the state is already terminal; the exposed path is disposing a still-Ready client, which `SessionManager.OpenSessionAsync`'s failure arm does (`Sessions/SessionManager.cs:133-140` — `MarkFaulted` faults the *session*, not the worker client) and `SessionWorkerClientFactory`'s catch avoids only because it calls `Kill` first (`SessionWorkerClientFactory.cs:120-132`). Impact is corrupted diagnostics/metrics, not data loss — but the fault fires on exactly the code path an operator investigates after a failed open, pointing them at backpressure that never happened. It also makes the `StageWorkerEvent` XML doc's "already terminal" claim false on one of the three completion paths.
|
||||
- **Recommendation:** Treat disposal as terminal in the guard — `if (_disposed || IsTerminalState()) return;` — or have `DisposeAsync` transition `_state` to `Closed` under `_syncRoot` before completing the channels (which would also give GWC-12's double-dispose interlock a natural home). Update the `StageWorkerEvent` doc to name all three completion paths.
|
||||
|
||||
### GWC-33 — A faulted-but-retained worker client keeps contributing its stale undelivered-event depth to the pull-model gauge until session disposal
|
||||
|
||||
- **Severity:** Info
|
||||
- **Category:** Performance & resource management (observability)
|
||||
- **Location:** `Workers/WorkerClient.cs:120-121` (gauge source registered at construction), `:426-429` (unregistered only in `DisposeAsync`), `:1011-1039` (`SetFaulted` completes channels but leaves staged+queued events uncounted-down); retention window: `Configuration/SessionOptions.cs` `FaultedGraceSeconds` + `Sessions/SessionManager.cs:283-289` (faulted sessions swept only when reapable)
|
||||
- **Description:** The GWC-24 gauge counts staged + queued events and is decremented only on consumer read. When a client faults, its channels are completed with the fault and never drained, so `_eventQueueDepth` freezes at whatever was undelivered. The registration is removed only in `DisposeAsync`; with a positive `FaultedGraceSeconds` (deliberately configurable so operators can observe faults before the reap) the dead client's frozen depth inflates the aggregate `worker event queue depth` gauge for the whole grace window. With the default `FaultedGraceSeconds = 0` the window is one sweep interval, so the default posture is fine — recorded so the next metrics pass knows it was seen.
|
||||
- **Recommendation:** Optional: dispose the gauge registration inside `SetFaulted`/`MarkClosed` (the comment at `:427-428` already articulates the principle: "the sum must not keep counting a client that is going away").
|
||||
|
||||
### GWC-34 — `EnsureDistributorCreated` recreates a never-started distributor after `DisposeAsync`; an attach that races a session teardown registers on a pump-less distributor and hangs silently
|
||||
|
||||
- **Severity:** Low
|
||||
- **Category:** Concurrency & thread safety (residual of GWC-27 — the disposal-side twin)
|
||||
- **Location:** `Sessions/GatewaySession.cs:1865-1875` (`DisposeAsync` nulls `_eventDistributor`; `_eventDistributorStarted` is never reset — `:41`, `:554-559`), `:535-563` (`EnsureDistributorCreated`: `_eventDistributor is null` → constructs a fresh distributor; `startNow` is false because the started flag is already latched), `:946-998` (`AttachEventSubscriber`: readiness gate under `_syncRoot`, then the distributor calls run *outside* the lock)
|
||||
- **Description:** GWC-27 closed the premature-attach poison; the mirror-image hole remains open on the teardown side. An `AttachEventSubscriber` (or `AttachEventSubscriberWithReplay` / `AttachInternalEventSubscriber`) that passes its readiness gate while the session is still `Ready`, then loses the CPU until `CloseAsync` + `DisposeAsync` complete, reaches `EnsureDistributorCreated` after `_eventDistributor` was nulled: a **new** distributor is constructed, `startNow` comes back `false` (the latch is one-way), the subscriber registers on a distributor whose pump will never start and whose channel will never be completed — `WaitToReadAsync` in `EventStreamService.cs:167` then blocks until the client's own deadline/cancel. No error, no log, no fault; the replacement distributor is also never disposed. Contrast the adjacent race that *is* handled: if disposal instead wins between `EnsureDistributorCreated` returning the **old** distributor and `Register`, the old distributor's disposed check throws `ObjectDisposedException` loudly and the attach rolls back (`RegisterSubscriber`, `SessionEventDistributor.cs:361-386`). Reachability is small (the attach must stall across an entire multi-second close + dispose between two adjacent statements), but the failure mode is the worst kind — silent, per-stream, and invisible to metrics (the backlog registration reports 0 forever). This is the same "one distributor per session, never reset" lifecycle assumption GWC-27 documented, violated from the other end.
|
||||
- **Recommendation:** Make the latch symmetric with the null-out: in `DisposeAsync`, set a `_eventDistributorDisposed` flag (or reuse a session-terminal check) under `_syncRoot` when nulling `_eventDistributor`, and have `EnsureDistributorCreated` throw `SessionManagerException(SessionNotReady)` instead of re-creating when it is set. One flag, one throw — the attach paths' existing catch/rollback already handles the exception.
|
||||
|
||||
### GWC-35 — `ApplyReconcile`'s state-delta repair is one-directional: an acked alarm replaced by a new unacked instance (or any acked→active regression) is absorbed silently by the snapshot replace
|
||||
|
||||
- **Severity:** Low
|
||||
- **Category:** Correctness & logic (alarm feed completeness; residual of GWC-26)
|
||||
- **Location:** `Alarms/GatewayAlarmMonitor.cs:666-691` (`ApplyReconcile` both-present loop: broadcasts only when `incoming.Value.CurrentState == AlarmConditionState.ActiveAcked`), snapshot replace at `:687-691`
|
||||
- **Description:** The GWC-26 repair covers three legs — missing→present (`Raise`), present→missing (`Clear`), and unacked→acked (`Acknowledge`). The fourth leg is absent: an alarm the cache holds as `ActiveAcked` that the worker snapshot now reports as `Active` (the acked instance cleared and re-raised as a new unacked instance while the live `Clear`+`Raise` transitions were lost — e.g. the internal alarm lease overflowed and disconnected, or worker-side event loss) is absorbed by the `_alarms` replace with no feed transition. `CurrentAlarms`/`QueryActiveAlarms` are correct (they read the replaced cache), but live `StreamAlarms`/dashboard subscribers keep rendering the alarm as acknowledged until its next genuine transition — for a re-raised alarm, precisely the state an operator must not misread. Reachability is much lower than the pre-GWC-26 window (a lease overflow restarts the whole monitor lifecycle, which re-snapshots every subscriber), so this is a completeness gap in the defense-in-depth rather than a live bug.
|
||||
- **Recommendation:** Generalize the both-present delta to any `CurrentState` change: broadcast `Acknowledge` for `→ ActiveAcked` and `Raise` (with the incoming snapshot's new instance identity) for `→ Active`. The existing `TransitionFromSnapshot` already carries the timestamps the NEXT-03 dedup needs to suppress the live duplicates.
|
||||
|
||||
### GWC-36 — `EventSubscriberReplayAttachment.OldestAvailableSequence` doc still describes the pre-GWC-25 empty-ring semantics
|
||||
|
||||
- **Severity:** Info
|
||||
- **Category:** Documentation & comments
|
||||
- **Location:** `Sessions/EventSubscriberReplayAttachment.cs:26-30` ("The oldest worker sequence still retained and replayable; `0` when nothing is retained.")
|
||||
- **Description:** GWC-25 changed the empty-ring-with-gap value to `highest observed + 1`; the distributor's out-param doc (`SessionEventDistributor.cs:430-440`), the proto, and `docs/Sessions.md` were all updated, but this record's XML doc was missed. "`0` when nothing is retained" is now exactly the value the fix exists to avoid — a reader implementing against this doc re-introduces the dead-stream formula.
|
||||
- **Recommendation:** Mirror the distributor's out-param wording (retained-oldest, or `highest observed + 1` when the ring is empty; `0` only when `Gap` is false).
|
||||
|
||||
### GWC-37 — The alarm subtag watch-list is resolved once per monitor lifecycle: a Galaxy-Repository outage at startup silently empties the subtag failover's coverage for the session's entire lifetime
|
||||
|
||||
- **Severity:** Low
|
||||
- **Category:** Error handling & resilience (alarm subtag fallback)
|
||||
- **Location:** `Alarms/GatewayAlarmMonitor.cs:330-357` (`SubscribeAlarmsAsync` resolves the watch list once and sends it in the `SubscribeAlarms` command); `Alarms/AlarmWatchListResolver.cs:54-83` (GR discovery failure → warn and continue with a config-only, typically empty set), `:141-145` (zero-target warning); no re-resolve anywhere — `ApplyProviderModeChangeAsync` (`GatewayAlarmMonitor.cs:513-566`) triggers only a reconcile on failover
|
||||
- **Description:** The subtag fallback's coverage is fixed at `SubscribeAlarms` time: the resolved `AlarmSubtagTarget` list travels once to the worker and is never refreshed while the monitor session lives. Discovery failure is deliberately non-fatal (`ResolveAsync` catches, warns, and returns whatever config contributes) — reasonable for the resolver, but the monitor then subscribes with an empty watch list and runs indefinitely. In `Auto` mode the alarmmgr primary works fine, so nothing looks wrong until the moment a failover to subtag fires — at which point the degraded provider covers zero alarms, the feed goes quiet, and the only evidence is a single `LogWarning` emitted possibly weeks earlier at startup. The same staleness applies to Galaxy objects added after monitor start (a documented design property), but the outage-at-startup case differs in kind: the operator configured discovery, it silently produced nothing, and the safety net the feature exists to provide is empty. The monitor's supervisor restarts (and thus re-resolves) only when the session faults — a healthy alarmmgr session never does.
|
||||
- **Recommendation:** Re-resolve and re-subscribe on the failover edge (`ApplyProviderModeChangeAsync` when `toMode == Subtag`), or treat "GR discovery failed AND the effective mode can need the watch list (Auto/ForceSubtag) AND zero targets resolved" as a lifecycle failure so the supervisor's backoff loop retries resolution instead of latching the empty set. At minimum, surface the empty-watch-list condition on the provider status (`Degraded`/`Reason`) rather than only in a startup log line.
|
||||
|
||||
### GWC-38 — `GatewayBrowseScopeProvider` fails open to the full Galaxy hierarchy when the ambient identity is absent, resting the browse-scoping control on an undeclared interceptor-ordering invariant
|
||||
|
||||
- **Severity:** Low
|
||||
- **Category:** Security (defense-in-depth; GalaxyRepository adoption)
|
||||
- **Location:** `Security/Authorization/GatewayBrowseScopeProvider.cs:14-25` (`identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty` → empty `BrowseSubtrees` → library treats as "no scoping"); registration-order dependency documented at `GatewayApplication.cs:151-154`
|
||||
- **Description:** The per-key browse-subtree scoping — the one piece of authorization mxaccessgw adds on top of the shared GalaxyRepository package — resolves to *unscoped* (full hierarchy) whenever `IGatewayRequestIdentityAccessor.Current` is null. The code's own comment says this is "safe only because the global interceptor has already authenticated and authorized the request first. Do not reorder the interceptor without revisiting this." That is a correct description of today's wiring and an honest warning, but it makes the control's fail state *open*: any future path that reaches the library's galaxy service without the interceptor populating the ambient identity (a new endpoint mapping, an interceptor scoped per-service instead of globally, a library upgrade changing pipeline order) silently grants every scoped key the full hierarchy, with nothing failing and nothing logged. Scoping is a constraint-narrowing control, so its absence is invisible in normal operation. (Whether identity-absent is legitimately reachable under `Authentication:Mode=Disabled` is the security reviewer's domain; this finding is about the fail-open shape itself.)
|
||||
- **Recommendation:** Distinguish "authenticated with no BrowseSubtrees constraint" (legitimately unscoped) from "no ambient identity" (invariant broken): when `Current` is null *and* API-key authentication is enabled, throw `RpcException(PermissionDenied)` (or log-and-deny) instead of returning unscoped. One branch converts a silent authorization bypass into a loud misconfiguration signal.
|
||||
|
||||
## Severity roll-up (new findings)
|
||||
|
||||
| Severity | Count | IDs |
|
||||
|----------|:-:|-----|
|
||||
| Critical | 0 | — |
|
||||
| High | 0 | — |
|
||||
| Medium | 0 | — |
|
||||
| Low | 6 | GWC-31, GWC-32, GWC-34, GWC-35, GWC-37, GWC-38 |
|
||||
| Info | 2 | GWC-33, GWC-36 |
|
||||
|
||||
Prior-finding verification: 7/7 cycle-2 Done claims hold (3 with caveats filed as GWC-32/34/35); 13 still-open cycle-1 findings re-confirmed open with none regressed; GWC-16 recommended for closure as a recorded Won't-fix.
|
||||
|
||||
## Still good / preserve under change
|
||||
|
||||
The cycle-2 remediation wave and the subsequent perf pass landed without breaking the core invariants, and several of them got structurally *stronger*. The backpressure topology is now bounded end to end again — worker queue → pipe → bounded staging (2×) → bounded consumer channel → bounded per-subscriber channels — with a loud fail-fast fault at every seam and a single observable depth gauge across the first two gateway stages; the GWC-24 implementation even added the startup overflow guard the design didn't ask for. The replay→live handoff argument is intact after the GWC-25 change, and the empty-ring sentinel arithmetic is documented in four places that all agree (distributor, proto, docs/Sessions.md, e2e test). The GWC-28 write-loop stamping is the textbook fix, and the `InvokeAsync` size-pre-check comment proves the interaction with it was reasoned about rather than stumbled past. The alarm monitor's attach-before-subscribe plus the NEXT-03 positive-match dedup give the alarm feed a defensible, *stated* delivery contract (at-least-once, idempotent-state), and the reconcile repairs are correctly quarantined to the feed layer — the "never synthesize events" rule for `MxEvent`s is respected everywhere, with the ReplayGap sentinel still the sole, documented control signal. Session state remains single-lock disciplined through the new detach-grace/faulted-grace/close-gate arms; the parallelized sweep and shutdown preserved the TOCTOU re-check and made the shutdown body exception-total with an honestly documented token asymmetry. The GalaxyRepository adoption is clean consumer wiring: scope-provider-before-package registration order is commented, the snapshot path is validated at startup, and the host-side projector is a pure function memoizable on the cache sequence. Comment quality across the new code is exceptional — most of this cycle's findings were *found via* comments that honestly documented their own residual assumptions ("safe only because…", "already terminal") — preserve that habit; it is what keeps one-layer-out defects findable. `GatewaySession` has grown again (2,173 lines) and the long-standing split-out recommendation stands.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Worker Process — Architecture Review (cycle 3)
|
||||
|
||||
## Scope & method
|
||||
|
||||
Date 2026-08-19 · commit `8b3e3b3` (`main`, clean) · macOS static review — the worker targets .NET Framework 4.8 x86 and was **not built or tested** here; every claim cites file:line evidence read directly from the working tree at HEAD. Scope: `src/ZB.MOM.WW.MxGateway.Worker` (STA runtime/pump, `MxAccessSession`/`MxAccessStaSession` COM lifetime, `Ipc/` pipe session + frame reader/writer, event queue and DrainEvents, value/write-completion caches, alarm consumers incl. the subtag failover, watchdog/heartbeat, program lifecycle/exit codes) and `src/ZB.MOM.WW.MxGateway.Worker.Tests`. This is a verification pass over the 2026-07-12 review (`archreview/2026-07-12/20-worker.md`, remediation IDs WRK-21..28 in `archreview/2026-07-12/remediation/20-worker.md` / `00-tracking.md`, plus NEXT-04/NEXT-05 from `remediation/90-candidate-findings-next-cycle.md`), a re-check of the still-open cycle-1 findings, and a deep review of everything landed since `4f5371f` (~50 worker commits, 53 files, +9,014/−427).
|
||||
|
||||
Changed-in-scope since `4f5371f`: the WRK-21/28/23/IPC-30 drain cluster (`33ba612`, `6bc3f9b`), the WRK-22/24/25/27/IPC-26 write-seam batch (`8df35cd`), the OnWriteComplete correlation feature (`fc23a65`..`b0e65d4`), the perf wave (pooled frame buffers `f56798a`, reverse tag index `afec56d`, message-driven completion waits `dc9424d`, signal-driven event drain `f4b065b`, exact-format timestamp parse `94fdc18`/`25cbe5c`, enqueue-time frame sizing `58d97ad`, value-cache borrowing `9871d47`, class-transition flush `aac7957`, drain-pass unparking `9130994`), the NEXT-04/NEXT-05 abandonment-observation batch (`84dbf20`, `3ef56be`, `b8b7b69`), session-owned transport disposal (`e913dab`, labeled WRK-31 in code), the alarm-side truncation/failover hardening (`f3e1de5`, `b5ea6bb`, `693a78d`, `b9fb0dd`), and the WorkerPipeSessionTests harness rework (`7da52b6`, `462850a`, `aaeb86b`, `753d070`).
|
||||
|
||||
**Finding-ID note:** the tracker instruction says new IDs continue at WRK-29, but `e913dab` already consumed **WRK-31** as an interim ID (cited in code at `Ipc/WorkerPipeSession.cs:45,177` and in `Ipc/WorkerPipeSessionTests.cs`); WRK-29/WRK-30 appear nowhere in the repo or its history but may have been burned in the same interim numbering. To guarantee no reuse, this review's new findings start at **WRK-32**.
|
||||
|
||||
## Executive summary
|
||||
|
||||
- All eight cycle-2 Done claims (WRK-21..28) are present, sound, and tested at HEAD; none is a false Done and none carries a functional caveat. The WRK-21 fix went beyond the design: the drain sizes events at enqueue time (`58d97ad`) so the byte-budget walk no longer holds the queue lock the STA needs, and `6bc3f9b` made the budget monotonic at the reserve boundary so drain-until-empty terminates even at the 1024-byte negotiated floor.
|
||||
- NEXT-04 (fault-observing continuations on abandoned/tombstoned frames) and NEXT-05 (lazy tombstone purge as a documented decision) are both verified — the observer discipline is applied uniformly in the writer (`ObserveAbandonedFault`/`ObserveFault`), and `e913dab`/`3ef56be` extended it to the abandoned net48 pipe read and overrun background tasks, with a GC-forcing `UnobservedTaskException` regression test.
|
||||
- The two riskiest perf changes checked out. The value-cache borrow (`9871d47`) is safe because the post-publish hook runs only after `Enqueue` and every consumer of the borrowed instances is serialize-only (verified through `MxAccessSession.SucceededRead` and the write-completion cache, which deliberately still clones); the class-transition flush (`aac7957`) and drain-pass unparking (`9130994`) preserve the "written and flushed before completion" contract, are lost-wakeup-free (a walked-away lock acquisition is detached, drains, and releases — never dropped), and cannot double-release the semaphore.
|
||||
- One cycle-1 finding was fixed incidentally: **WRK-13** (unpooled writer buffers) is resolved by `f56798a` — the writer now rents one combined prefix+payload buffer from `ArrayPool` (`Ipc/WorkerFrameWriter.cs:675-687`). The old tracker should be updated. WRK-18's stale-doc half is also discharged (the design doc now describes the implemented fail-fast and honestly names the generic exit code); its dedicated-exit-code half stays open.
|
||||
- The remaining cycle-1 opens (WRK-02/03/05/08/09/10/14/16/17/18/19 and the WRK-02/03 slice of WRK-20) are all still present. WRK-05 is materially narrowed: in auto/failover mode `FailoverAlarmConsumer.RunPrimary` absorbs primary COM failures (`MxAccess/FailoverAlarmConsumer.cs:284-291`), but the default alarmmgr-only path is still one-poll-exception-fatal (`MxAccess/MxAccessStaSession.cs:392-405`).
|
||||
- Fresh sweep found no Medium or higher. Three new Lows: recency-based OnWriteComplete correlation can attribute a stale completion from an earlier write to the current write's reply (WRK-32); the discarded command-task observer is the one discarded task in the worker without a fault-observing continuation (WRK-33); the StaHung fault writes the in-flight correlation id into `WorkerFault.CommandMethod` (WRK-34, pre-existing since 2026-04-26).
|
||||
|
||||
## Prior-finding verification — cycle 2 (all tracked Done) and NEXT items
|
||||
|
||||
| ID | Claimed status | Verified? | Evidence (file:line at HEAD) | Notes |
|
||||
|----|----------------|-----------|------------------------------|-------|
|
||||
| WRK-21 (= IPC-23) | Done | **Yes** | Byte-budgeted `Drain(uint, int)` dequeues only events known to fit, leaves a non-fitting head queued, reports `OversizedHeadSequence` (`MxAccess/MxAccessEventQueue.cs:389-429`); per-event size memoized at enqueue outside the drain's lock hold (`:248-281`, `:502-509`, slack constants `:73-81`); reply builder consumes it with truncation `DiagnosticMessage` (`Ipc/WorkerPipeSession.cs:978-1020`); budget = `max(MaxMessageBytes − 64 KiB, MaxMessageBytes/2)` so it stays monotonic at the reserve boundary (`:1034-1039`, `6bc3f9b`); shared result type is net48-safe (`MxAccess/WorkerEventDrainResult.cs`). Backstops: control-reply write catches `MessageTooLarge` and answers with a small `InvalidRequest` reply (`Ipc/WorkerPipeSession.cs:847-864`), STA command replies get the same catch (`:1136-1147`), and the fallback write itself is size-guarded so a degenerate negotiated max cannot make the backstop fatal (`:876-900`). Tests: `WorkerPipeSessionTests.DrainEvents_ByteHeavyQueue_ReplyIsBoundedAndSessionSurvives` (:571), `DrainEvents_RepeatedCalls_RecoverAllEventsWithoutLoss` (:630), `ControlReplyTooLarge_WritesErrorReplyInsteadOfDying` (:770), `CommandReplyTooLarge_WritesErrorReplyInsteadOfFaulting` (:815), plus `MxAccessEventQueueTests` byte-budget cases. | Sound, and stronger than designed (no sizing walk under the STA-shared lock). No diagnostics command is session-fatal on size anymore. |
|
||||
| WRK-22 (= IPC-26) | Done | **Yes** | `PendingFrame.Claimed` interlock documented and mutated only under `_gate` (`Ipc/WorkerFrameWriter.cs:54-62`); cancelled waiter tombstones only unclaimed frames (`:169-183`, `:308-338`); `DequeueNext` skips cancelled frames and claims atomically in the same critical section (`:584-618`); contract documented in the method doc (`:105-112`) and `docs/WorkerFrameProtocol.md:203-211`. Tests `WorkerFrameProtocolTests.WriteAsync_CancelledWhileWaitingForLock_FrameIsNeverWritten` (:695), `WriteAsync_CancelledEventFrame_DoesNotTrailShutdownAck` (:800). | Sound. The claimed-mid-write residual window is documented as by-design. |
|
||||
| WRK-23 | Done | **Yes** | Peek-stamp-commit: candidate stamped before the size checks (sequence participates in `CalculateSize`), counter committed only once the frame is certain to be written, so a per-frame rejection burns no number (`Ipc/WorkerFrameWriter.cs:648-666`; comment `:72-76`). | Wire sequence stays contiguous across rejections; the gap-free concurrent-sequence test's guarantee is now genuinely true. |
|
||||
| WRK-24 | Done | **Yes** | `MinNegotiableFrameBytes = 1024` matching the gateway validation floor, rejected at the handshake with `InvalidConfiguration` (`Ipc/WorkerFrameProtocolOptions.cs:30`, `:144-150`); range documented in the property doc and `docs/WorkerFrameProtocol.md`. | Also closes the degenerate config that could have made WRK-21's fallback swallow reachable (`Ipc/WorkerPipeSession.cs:866-875` documents that chain). |
|
||||
| WRK-25 | Done | **Yes** | `WriteBatchAsync` enqueues the whole batch under one `_gate` acquisition, drains under one lock, awaits every completion so no per-frame rejection faults unobserved, rethrows the first failure in batch order (`Ipc/WorkerFrameWriter.cs:236-306`); the event drain loop submits drained batches through it (`Ipc/WorkerPipeSession.cs:615-630`). Tests `WriteBatchAsync_FlushesOnceAndPreservesOrder` (:839), `WriteBatchAsync_ControlFrameQueuedDuringBatch_JumpsRemainingEvents` (:876), `WorkerPipeSessionTests.EventBurst_DrainLoopCoalescesFlushes` (deflaked `50322ba`). | The WRK-12 coalescing now engages on the exact path it was built for: an uninterrupted N-event burst pays one flush. `aac7957` later refined the mixed-class case (see deep review). |
|
||||
| WRK-26 (discharges IPC-29) | Done | **Yes** | `docs/MxAccessWorkerInstanceDesign.md:717-751` — Outbound Queues rewritten to the two-class scheduler, explicitly records the accepted collapse of the five-level order and why, and rewrites the overflow paragraph to the implemented fail-fast (naming the still-generic `UnexpectedFailure` exit code honestly — WRK-18's exit-code half remains tracked); `docs/WorkerFrameProtocol.md:79+` "Write Scheduling And Sequencing" covers priority classes, write-time stamping, per-frame vs stream-failure semantics, flush coalescing, and cancellation. | No stale five-level prose remains. |
|
||||
| WRK-27 | Done | **Yes** | `volatile bool staAlarmPollInProgress` flipped on the STA exactly around `PollOnce` (`MxAccess/MxAccessStaSession.cs:64-70`, `:352-365`); surfaced via `WorkerRuntimeHeartbeatSnapshot.StaCallInProgress` (`MxAccess/WorkerRuntimeHeartbeatSnapshot.cs`, ctor default keeps net48-safe plain ctor); watchdog suppression honors it alongside the in-flight correlation id up to the ceiling (`Ipc/WorkerPipeSession.cs:1359-1370`). Test `WorkerPipeSessionTests.Watchdog_StaCallInProgress_SuppressedUntilCeiling` (:1260). | A slow-but-healthy poll now gets the 75 s ceiling, not the 15 s grace; a poll blocking past the ceiling still faults, per the ceiling's contract. |
|
||||
| WRK-28 | Done | **Yes** | `GatewayContractInfo.MaxDrainEventsPerCommand = 10_000` in Contracts (`src/ZB.MOM.WW.MxGateway.Contracts/GatewayContractInfo.cs:33`), consumed by the gateway validator (`src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs:82-86`) and the worker clamp (`Ipc/WorkerPipeSession.cs:992-994`). | Single shared constant; the desync failure mode is removed, not merely detected. |
|
||||
| NEXT-04 | Resolved 2026-08-10 | **Yes** | Unconditional fault-observing continuation on every frame a cancelled caller abandons — `TombstoneIfUnclaimed`/`TombstoneUnclaimed` → `ObserveAbandonedFault` → `ObserveFault` (`Ipc/WorkerFrameWriter.cs:308-372`); a cancelled task never fires `OnlyOnFaulted`, so the unconditional attach is safe, and it runs outside `_gate` because an already-faulted task continues inline. Also applied to the detached lock wait (`:389-426`) and, session-side, to overrun background tasks and the abandoned pipe read (`Ipc/WorkerPipeSession.cs:521-576`). Regression test drives the exact abandonment and asserts a marker exception never reaches `TaskScheduler.UnobservedTaskException` (`WorkerFrameProtocolTests:733-785`). | Verified; the discipline is now near-uniform (the one residual gap is WRK-33). |
|
||||
| NEXT-05 | Resolved as documented decision | **Yes** | `DequeueNext` still lazily skips tombstones (`Ipc/WorkerFrameWriter.cs:588-618`); the rationale — any subsequent write drains both queues, the heartbeat bounds tombstone residency to one interval, and eager `Queue<T>` rebuilds would add ordering-invariant surface next to the WRK-22 interlock — is recorded in `docs/WorkerFrameProtocol.md:218-221`. | Decision recorded where the review asked for it. |
|
||||
|
||||
Done claims that failed verification: **none**. Regressions in previously-verified fixes: **none** — WRK-01 (pump refresh, `Sta/StaRuntime.cs:95-100`), WRK-04/GWC-28 (write-time sequence stamping, now peek-stamp-commit), WRK-06 (converter cache, extended by `94fdc18`'s compiled accessors), WRK-07 (two-class scheduler), WRK-11 (ownership transfer, now load-bearing for both the value-cache borrow and enqueue-time sizing), WRK-12 (flush coalescing, finally effective via WRK-25), and WRK-15 (doc names) all remain intact at HEAD.
|
||||
|
||||
## Prior-finding verification — still-open cycle-1 items
|
||||
|
||||
| ID | Status at HEAD | Evidence | Notes |
|
||||
|----|----------------|----------|-------|
|
||||
| WRK-02 | **Still open** | `Sta/StaRuntime.cs:265-269` (loop exception stored in write-only `startupException` after startup; only `startedEvent.Set()`); `InvokeAsync` (`:161-189`) checks disposal and shutdown but not thread death — work enqueued after `ThreadMain` unwinds is consumed by nobody and its task hangs forever (`CancelQueuedCommands` in the `finally` at `:272` only drains what was queued at death). | Highest-value remaining worker item, unchanged three cycles running. |
|
||||
| WRK-03 | **Still open** | `Ipc/WorkerPipeSession.cs:1226-1229` — `TryStartCommandTask` silently `return`s when `_acceptingCommands` is false: no reply, no log. | The gateway's correlation wait times out blind. |
|
||||
| WRK-05 | **Still open, narrowed** | `MxAccess/MxAccessStaSession.cs:392-405` — any exception from one poll records a fault and stops the loop; the drain loop turns it into session termination (`Ipc/WorkerPipeSession.cs:589-598`). Narrowed: in auto mode the `FailoverAlarmConsumer` absorbs primary COM failures inside `PollOnce` (`MxAccess/FailoverAlarmConsumer.cs:284-291`, catches all but OOM and counts toward failover), so a transient alarmmgr E_FAIL no longer escapes. The default alarmmgr-only mode (`Ipc/WorkerPipeSession.cs:78`, and subtag-source failures in standby mode) is still one-failure-fatal. | Worth re-scoping to "non-failover alarm modes" if kept. |
|
||||
| WRK-08 | **Still open** | `Ipc/WorkerPipeSession.cs:487-491` — the message-loop `finally` cancels the drain loop after the shutdown ack; events already drained from the queue (`:600`) whose batch write is cancelled are tombstoned and lost, and the residual queue is never shipped. `docs/MxAccessWorkerInstanceDesign.md` still documents no discard (`grep residual/unshipped` finds nothing). | Same scale as before the batch change (the old loop also drained 128 ahead of writing). Documentation half remains the cheap part. |
|
||||
| WRK-09 | **Still open** | No `AppDomain.UnhandledException`/`TaskScheduler.UnobservedTaskException` handler anywhere under `src/ZB.MOM.WW.MxGateway.Worker/` (grep at HEAD; the only mentions are comments noting the absence). | The NEXT-04 observer network reduces the practical exposure but is not a process-level backstop. |
|
||||
| WRK-10 | **Still open** | `WorkerApplication.cs:112-139` — all three catch blocks log `exception_type` only; the redactor (`Bootstrap/WorkerLogRedactor.cs`) is available and unused here. | Still a cheap diagnostics win. |
|
||||
| WRK-13 | **Resolved (side effect of `f56798a`)** | `Ipc/WorkerFrameWriter.cs:675-687` — single `ArrayPool<byte>.Shared.Rent` for prefix+payload, one stream write, returned in `finally`; matches the reader's pooling. | Flip to Resolved in `archreview/remediation/00-tracking.md` with commit `f56798a`. |
|
||||
| WRK-14 | **Still open** | `Ipc/WorkerPipeSession.cs:33-62` `_camelCase` vs `MxAccess/MxAccessStaSession.cs:50-71` and `Sta/StaRuntime.cs:10-24` bare `camelCase`. | Cosmetic; new files followed their host file's convention, so the split persists. |
|
||||
| WRK-16 | **Still open** | `Ipc/WorkerPipeSession.cs:1449-1544` — seven `CreateEnvelope`/`CreateBaseEnvelope` pairs; `Ipc/WorkerPipeClient.cs` still has 7 public constructors. | |
|
||||
| WRK-17 | **Still open** | `WorkerApplication.cs:110-119` maps every `WorkerFrameProtocolException` (including `EndOfStream`) to exit 6 `ProtocolViolation`. | |
|
||||
| WRK-18 | **Half discharged** | Doc half done: `docs/MxAccessWorkerInstanceDesign.md:743-751` now describes the implemented fail-fast and names the generic `UnexpectedFailure` exit honestly. Code half open: a drain-loop fault still exits 1 via the generic catch (`WorkerApplication.cs:131-139`); no dedicated overflow/event-fault exit code. | |
|
||||
| WRK-19 | **Still open** | `Sta/StaCommandDispatcher.cs` — no logger field, no start/end/failure log anywhere in the file. | |
|
||||
| WRK-20 | **Residual only** | The WRK-21..28 batch added its full test matrix (byte-budget drain, size backstops, cancellation/tombstoning, batch flush, floor handshake, watchdog suppression), plus teardown `UnobservedTaskException` tests (`WorkerPipeSessionTests:2328-2434`). Still missing: STA-thread-death (WRK-02) and command-refused-during-shutdown (WRK-03) tests, because their fixes are open. | |
|
||||
|
||||
## Deep review of the new code
|
||||
|
||||
**Frame-writer unparking (`9130994`) and class-transition flush (`aac7957`).** `WriteAsync` now races its frame's completion against the lock acquisition (`Ipc/WorkerFrameWriter.cs:148-198`): a caller whose frame is delivered by another drainer returns at the boundary flush instead of at the winner's release, and the acquisition it walks away from is handed to `DetachLockWait` (`:389-399`), whose continuation either observes a cancelled wait (no count taken, nothing to release) or drains-and-releases (`:409-452`). I probed this seam hard: acquisition and cancellation are mutually exclusive in `SemaphoreSlim`, so no count can leak and no double-release path exists (`DrainDetachedAsync` releases in a `finally` that runs exactly once per successful acquisition); a frame enqueued between a drainer's last `DequeueNext` and its release is picked up by the detached pass, so no lost wakeup; every discarded task in the chain carries the NEXT-04 observer. The drain flushes and completes at each control→event boundary (`:484-511`), so a heartbeat/reply/fault's delivery point is never charged for the event backlog behind it; frames completed at a boundary leave `written`, so a later flush failure in the same pass cannot reach back and fail an already-delivered frame (`:543-557`) — the failure-attribution edges are all correct. Cost claims in the comments are accurate: a pure-event pass still pays one flush; only a genuinely mixed pass pays one extra per class transition. Residual (accepted, documented at `Ipc/WorkerPipeSession.cs:186-191`): a detached acquisition can schedule an empty drain pass after session teardown — harmless because the queues are empty and an empty pass writes nothing.
|
||||
|
||||
**Value-cache borrowing (`9871d47`).** The hunt for aliasing bugs came up clean. The borrow is sound on three legs, each verified: (1) ordering — `MxAccessBaseEventSink.EnqueueEvent` runs the `postPublish` cache write only after `eventQueue.Enqueue` returns (`MxAccess/MxAccessBaseEventSink.cs:248-295`), so the event is already write-once (WRK-11 ownership plus the sequence/timestamp stamps under the queue lock); (2) consumers — `MxAccessSession.SucceededRead` copies the borrowed `Statuses` element references into a fresh `RepeatedField` on the reply and reference-shares `Value`/`SourceTimestamp` (`MxAccess/MxAccessSession.cs:1066-1097`), and everything downstream only serializes (the worker↔gateway process boundary prevents any other consumer from aliasing); (3) the size-memoization coupling — a post-enqueue mutation would now also invalidate `QueuedEvent.Size`, and the ownership contract text in both classes names that hazard explicitly (`MxAccess/MxAccessValueCache.cs:76-108`, `MxAccess/MxAccessEventQueue.cs:15-32`). The write-completion cache deliberately still clones and documents why the asymmetry is not an oversight (`MxAccess/MxAccessWriteCompletionCache.cs:77-89`). Protobuf reads (`CalculateSize`/`WriteTo`) from multiple threads on a frozen message are safe, and every cross-thread hand-off passes through a lock.
|
||||
|
||||
**Enqueue-time frame sizing (`58d97ad`).** `CalculateWorkerEventSize` reconstructs the generated `CalculateSize` arithmetic for exactly the two stamped fields (`MxAccess/MxAccessEventQueue.cs:493-509`); I checked the tag math — `worker_sequence` (field 9, varint) and `worker_timestamp` (field 10, length-delimited) are both single-byte tags, the `WorkerEvent.event` wrapper (field 1) matches the `DrainEventsReply.events` packing shape exactly, and the +8 `RepeatedFieldOverheadBytes` slack absorbs the documented renumbering hazard (`:66-81`). Clearing the two fields before measurement makes the pre-lock measurement exact even against a caller that pre-filled them. The wake signal (`SignalWake`, `:471-491`) is a level, not a count; I walked the check-then-release race against a concurrent consumer and every interleaving either sees the event in its re-drain or leaves a pending permit — no lost wake, and the 25 ms fallback tick bounds any hypothetical future unsignalled mutation.
|
||||
|
||||
**Session-owned transport disposal and abandonment observation (`e913dab`, `3ef56be`, WRK-31).** The session now owns the pipe stream and disposes it as the last teardown step — the only thing that unblocks a net48 pipe read parked in the kernel — then awaits the abandoned read's resulting fault (`Ipc/WorkerPipeSession.cs:143-290`). The `_pendingReadTask` bookkeeping preserves the reader's single-consumer invariant through teardown (set at issue, cleared before the loop's own await, read only after the loop's task completes — the await supplies the happens-before), and two `Debug.Assert`s pin it (`:447-449`, `:470-472`). `ObserveBackgroundTaskStopAsync` correctly separates the logging budget from observation: an overrun task gets `ObserveFaultWhenever` before the method returns (`:521-558`), so the 1 s budget is a diagnostics decision, not a correctness one. `DisposeTransportStream`'s total catch is well argued (nothing thrown while closing a handle may displace the session's terminal exception, `:212-250`), and `WorkerPipeClient` keeps its `using` as an explicitly-documented idempotent backstop (`Ipc/WorkerPipeClient.cs:153-165`).
|
||||
|
||||
**Oversized-event death (IPC-30) through the batch path.** A `MessageTooLarge` surfacing from the batch's awaited completions is mapped back to the first envelope in batch order that overshoots the negotiated max, logged with identity-but-never-value fields, written as a structured `ProtocolViolation` fault, and then thrown (`Ipc/WorkerPipeSession.cs:626-725`). `FindOversizedEvent`'s recomputation is consistent with what the writer rejected (the rejected envelope retains its tentative sequence stamp, so `CalculateSize` agrees), and the unreachable-fallback comment is honest. Other per-frame rejection codes still unwind the session as worker bugs — the deliberate IPC-30 scope.
|
||||
|
||||
**Write-completion correlation (`fc23a65`..`b0e65d4`).** Parity-clean in the load-bearing respects: no synthesized events (the completion rides the unary reply's `statuses` only), acceptance-only `Hresult`/`ProtocolStatus` either way, expiry yields empty statuses ("unconfirmed, not failed"), zero timeout restores pure fire-and-forget, and the pump-wait keeps the STA dispatching for the whole wait via the shared `MsgWaitForMultipleObjectsEx` declaration (`Sta/StaNativeMethods.cs`, single-copy rationale documented). `StaWaitHelper` handles the failed-wait degrade (one-shot trace + blind sleep, `Sta/StaWaitHelper.cs:104-112`) and documents the `MWMO_INPUTAVAILABLE` drain-to-empty contract that would otherwise busy-spin. The one soft spot is attribution across successive writes to the same handle — WRK-32 below.
|
||||
|
||||
**Alarm subtag failover and truncation.** The production wiring works despite the `standbyFactory: null` default: the `comFactory` threaded through `MxAccessStaSession.StartAsync` lets `AlarmCommandHandler.BuildStandby` construct the `SubtagAlarmConsumer` over `LmxSubtagAlarmSource` (`MxAccess/AlarmCommandHandler.cs:206-248`); mode selection (alarmmgr / subtag / auto-failover) matches the documented matrix, provider-mode changes are surfaced as queue events with handler detach on unsubscribe/dispose (`:159-183`, `:258-267`). WnWrap truncation handling is careful where it matters: a truncated fetch merges into the retained snapshot instead of clearing it (`ApplySnapshotUpdate`), so Clear transitions are never synthesized from records that merely fell off a truncated fetch, and the truncation verdict rides out of the same locked fold that produced the transitions (`FoldFetch`), with a rate-limited warning. STA affinity for the wnwrap COM object is asserted at every consumer entry (`MxAccess/MxAccessStaSession.cs:410-440`), and the affinity-violation exception is deliberately routed to the fault path rather than the graceful-stop path (`:378-405`).
|
||||
|
||||
**Handle-registry reverse index (`afec56d`).** Every mutation maintains all four secondary indexes and the three memoized views; I walked the consistency edges — re-registration with a changed definition drops the stale index entry first, per-server teardown removes exactly the owned sets, advice de-indexing trims empty containers, and the sorted-insertion keeps `GetItemHandlesForDefinition` in the same order the old linear scan visited (`MxAccess/MxAccessHandleRegistry.cs`). The registry is STA-confined by documented contract, so the lock-free indexes are sound. The returned live-view caveat is documented at the API.
|
||||
|
||||
**Timestamp fast path (`94fdc18`, `25cbe5c`).** The per-culture derived formats are validated by round-tripping a probe instant before they ever reach the event path, so a pathological culture pattern (quoted `ss`, existing fraction) degrades to no-fast-path instead of a throwing `TryParseExact`; the fast path is provably a shortcut, never a different answer, because the formats are derived from the same culture the general parse would use (`MxAccess/MxAccessEventMapper.cs:373-509`).
|
||||
|
||||
**Test harness rework.** `FakeRuntimeSession` now mirrors production's coalesced wake signal (never-disposed `SemaphoreSlim`, same rationale comment), models STA-pump-at-heartbeat (`refreshStaActivityOnCapture`), and its 30 s dispatch safety net is decoupled from scenario timing with the reasoning written down (`TestSupport/FakeRuntimeSession.cs:23-51`). The teardown tests arm `TaskScheduler.UnobservedTaskException` and force GC — the strongest possible assertion shape for the NEXT-04/WRK-31 class of fix. Test counts in scope: 40 facts/theories in `WorkerPipeSessionTests`, 31 in `WorkerFrameProtocolTests`, 23 in `MxAccessEventQueueTests`, plus new suites for the completion cache, wait helper, registry, and WnWrap XML parsing.
|
||||
|
||||
## New findings
|
||||
|
||||
**WRK-32 — Low (error handling / documentation).** Write-completion correlation is recency-based per handle; a late `OnWriteComplete` from an earlier write can be reported as the current write's outcome.
|
||||
Evidence: the baseline is `CurrentVersion` captured before the COM call (`MxAccess/MxAccessCommandExecutor.cs:447-450`, `:522-528`), and `AwaitWriteCompletion` copies onto the reply *any* completion whose version exceeds it (`:1005-1027`; version test only, `MxAccess/MxAccessWriteCompletionCache.cs:162-169`). MXAccess's `OnWriteComplete` carries no correlation token, only the handle pair — so if write A to a tag times out unconfirmed (its callback still undelivered) and the client immediately writes B to the same tag, A's stale callback dispatching during B's pump-wait bumps the version past B's baseline and A's status rows ride B's reply. The executor doc comment claims "the OnWriteComplete row *matching* a WriteSecured/WriteSecured2 call" (`:996-1003`), and neither `docs/MxAccessWorkerInstanceDesign.md` nor the proto comments mention the ambiguity (grep for misattribution/stale/earlier-write finds nothing). Impact is bounded — the statuses are an enrichment field, `Hresult`/`ProtocolStatus` stay acceptance-only, and back-to-back same-tag writes with a timed-out predecessor is the only exposed sequence — and the ambiguity is inherent to the COM surface (a native client correlating by handle has the same problem, so mechanical parity is arguably preserved).
|
||||
Recommendation: document the recency-based contract at the executor, in the design doc's write-completion section, and in the reply-field proto comment; additionally, run one `pumpStep()` immediately before capturing the baseline so an already-queued stale callback is folded into the baseline instead of the wait window. Full closure is impossible without an MXAccess-side token — say so where consumers will read it.
|
||||
|
||||
**WRK-33 — Low (concurrency / NEXT-04 discipline gap).** The discarded command-task observer is the one discarded task in the worker without a fault-observing continuation.
|
||||
Evidence: `TryStartCommandTask` discards its wrapper — `_ = ObserveCommandTaskAsync(commandTask);` (`Ipc/WorkerPipeSession.cs:1235`) — and `ObserveCommandTaskAsync` catches only `OperationCanceledException` (`:1238-1254`). `ProcessCommandAsync` handles its own failures, but its catch path ends in `TryWriteFaultAsync`, whose filter is exactly `IOException`/`ObjectDisposedException`/`WorkerFrameProtocolException` (`:1440-1447`); any other exception escaping that write — or a future edit anywhere in the catch path — propagates out of `ProcessCommandAsync`, faults the discarded wrapper, and surfaces only as a finalizer-time unobserved-task exception in a process that installs no handler (WRK-09). Every other discarded task in this codebase now carries an observer (`ObserveFaultWhenever` lives 660 lines up in this same file); the gap is unlikely to fire but contradicts the discipline the NEXT-04/WRK-31 batch established and tested.
|
||||
Recommendation: `ObserveFaultWhenever(ObserveCommandTaskAsync(commandTask));`, or broaden `ObserveCommandTaskAsync` to catch-all-and-log (the redaction-safe fields are already at hand in `LogCommandResultDropped`'s shape).
|
||||
|
||||
**WRK-34 — Low (documentation / diagnostics).** The StaHung fault writes the in-flight correlation id into `WorkerFault.CommandMethod`.
|
||||
Evidence: `ReportWatchdogFaultIfNeededAsync` builds its fault with `CreateFault(WorkerFaultCategory.StaHung, snapshot.CurrentCommandCorrelationId, $"STA activity is stale by {staleFor}.")` (`Ipc/WorkerPipeSession.cs:1384-1389`), whose second parameter is `commandMethod` (`:1712-1728`) — so the wire fault's `command_method` field carries a correlation id (or empty), a correlation id never lands in a correlation-shaped field, and a gateway/dashboard consumer reading `CommandMethod` as a method name gets an opaque id string. Pre-existing since `4a3560c` (2026-04-26), never previously flagged; noted now because the WRK-27 work touched this exact branch and the field's semantics matter more once polls also suppress here.
|
||||
Recommendation: set `CommandMethod` to a stable label (empty, or `"Watchdog"`), and carry the stuck command's correlation id in the fault's diagnostic message or the envelope's `CorrelationId` where the gateway already correlates.
|
||||
|
||||
## Severity roll-up (new findings)
|
||||
|
||||
| Severity | Count | IDs |
|
||||
|----------|-------|-----|
|
||||
| High | 0 | — |
|
||||
| Medium | 0 | — |
|
||||
| Low | 3 | WRK-32, WRK-33, WRK-34 |
|
||||
|
||||
## Still good / preserved
|
||||
|
||||
Every structural strength recorded in the prior two reviews is intact at HEAD, and several were deepened. The STA core still runs the canonical `MsgWaitForMultipleObjectsEx` + `PeekMessage`/`DispatchMessage` loop with the wake event and 50 ms idle pump (`Sta/StaRuntime.cs:255-261`), and the subtle Win32 wait contract now has a single shared declaration with the drift hazard written down (`Sta/StaNativeMethods.cs`). All MXAccess COM calls remain on the dedicated STA (commands via the dispatcher, alarm polls via `InvokeAsync` with an affinity assertion, the consumer factory on-thread at `MxAccess/MxAccessStaSession.cs:282-296`); COM lifetime discipline (`FinalReleaseComObject`, sink-detach-before-release, UnAdvise → RemoveItem → Unregister, alarm handler disposed on the STA before data-side teardown) is unchanged. Nothing synthesizes events — the completion-correlation feature explicitly kept `OperationComplete` untouchable and the truncation work explicitly refuses to fabricate Clear transitions. Fail-fast backpressure stands (no drop policy; overflow self-records the first fault; the dispatcher's 128-entry bound rejects loudly). Credentials and values stay out of logs (the oversized-event fault logs sizes and identity only, `AuthenticateUser` passes the password straight through, the DrainEvents truncation message names sequences not values). net48 constraints are respected in every new type (plain ctors, get-only properties, `readonly struct`, no init/records — several carry the CS0518 rationale in their remarks). The IPC layer's failure-attribution story is now the best part of the worker: written-and-flushed-before-completion, per-frame vs stream-failure separation, boundary-flush completion, tombstone/claim interlock, and a fault observer on effectively every abandoned task, each with a regression test that asserts the invariant rather than the implementation. Comment quality across the new code is exceptional — ownership contracts, memory-model reasoning, and rejected alternatives are recorded at the exact seams where the next editor would otherwise re-litigate them.
|
||||
|
||||
## Verification needed on Windows (windev)
|
||||
|
||||
- This review is static: the x86 worker was not built and `Worker.Tests` were not run here. The tracking log and repo memory record the worker suite fully green on windev after the harness fixes (`7da52b6`/`462850a`/`aaeb86b`), and the WRK-21 cluster carries windev evidence (`c992568`); any fix for WRK-32..34 needs an x86 build plus filtered `MxAccessCommandExecutorTests`/`WorkerPipeSessionTests` runs there.
|
||||
- WRK-32's pre-baseline pump mitigation should get a windev test that records a stale completion into the fake's message path before the second write's baseline and asserts it does not ride the second reply.
|
||||
- WRK-33 is assertable with the existing `UnobservedTaskException`-arming test shape (`WorkerPipeSessionTests:2328-2434`): drive an exception through `TryWriteFaultAsync`'s filter gap with a throwing fake writer and assert nothing reaches the finalizer.
|
||||
- Live MXAccess only: the WRK-05 narrowing (failover absorbing a transient alarmmgr E_FAIL without a session fault) deserves a live-rig confirmation next time the opt-in alarm smoke runs; the rig's object-driven alarms make this drivable only from the simulator side.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Contracts & IPC Protocol — Cycle-3 Review
|
||||
|
||||
- **Date:** 2026-08-19
|
||||
- **Commit:** `8b3e3b3` (`main`, clean)
|
||||
- **Baseline:** `4f5371f` (2026-07-12 cycle-2 review; ~288 commits since, ~55 of them touching the contracts, frame-protocol, codegen, or IPC-doc paths in scope)
|
||||
- **Method:** static review on macOS; every cited file read at HEAD. No build, no test, no source change. `git diff 4f5371f..HEAD -- '*.proto'` walked line by line; generated trees grep-checked for every new symbol; the GalaxyRepository package proto compared against the sibling `scadaproj` checkout.
|
||||
- **Scope:** `src/ZB.MOM.WW.MxGateway.Contracts` (three protos, `Generated/` hygiene, `net10.0;net48` multi-targeting, `galaxy_repository.proto` retention vs the `ZB.MOM.WW.GalaxyRepository` package), the frame protocol on both sides (`Worker/Ipc/*`, `Server/Workers/WorkerFrame*`, `WorkerClient` envelope/sequence/size handling, hello/handshake, `max_frame_bytes` negotiation, DrainEvents byte bound), the codegen/descriptor pipeline (`scripts/check-codegen.ps1` Checks 1–4, `scripts/publish-client-proto-inputs.ps1`, `clients/proto/*`, vendored Rust protos, Go/Python/Java binding freshness guards, Gradle `checkGeneratedClean`), proto-evolution hygiene, and currency of `docs/WorkerFrameProtocol.md`, `gateway.md` (IPC envelope section), `docs/Grpc.md`, `docs/ClientProtoGeneration.md`, `docs/Contracts.md`. Worker-internal mechanics of the drain/write seam are owned by `20-worker.md` and are cross-referenced, not re-reviewed.
|
||||
- **Prior reports:** `archreview/2026-07-12/30-contracts-ipc.md` (cycle 2); `archreview/30-contracts-ipc.md` (cycle 1); remediation status sources `archreview/2026-07-12/remediation/00-tracking.md` + `30-contracts-ipc.md`, `90-candidate-findings-next-cycle.md`; first-cycle backlog `archreview/remediation/00-tracking.md`.
|
||||
|
||||
## Proto evolution since baseline — verified additive-only
|
||||
|
||||
`git diff 4f5371f..HEAD -- src/ZB.MOM.WW.MxGateway.Contracts/Protos/` contains exactly four wire-affecting lines, all additive with next-free tags:
|
||||
|
||||
| Change | Location | Tag check |
|
||||
|---|---|---|
|
||||
| `bool snapshot_truncated = 2;` on `QueryActiveAlarmsReplyPayload` | `mxaccess_gateway.proto:735` | message previously ended at field 1 |
|
||||
| `bool from_truncated_snapshot = 16;` on `ActiveAlarmSnapshot` | `:951` | previous highest was `source_provider = 15` |
|
||||
| `AlarmSnapshotStatus snapshot_status = 5;` oneof arm on `AlarmFeedMessage` | `:1028` | previous highest arm was `provider_status = 4` |
|
||||
| new `message AlarmSnapshotStatus { bool truncated = 1; }` | `:1048-1054` | new type |
|
||||
|
||||
Everything else in the diff is comment-only (write-completion `statuses` contract at `:538-551`, DrainEvents truncation at `:702-705`, `ReplayGap` empty-ring sentinel at `:797-801`, `AlarmFeedMessage` open sequence at `:1006-1012`, and the `GatewayHello.max_frame_bytes` byte-cap sentence at `mxaccess_worker.proto:50-52`). No renumbering, no repurposing; `reserved 1` / `reserved "session_id"` intact on the two retired fields (`mxaccess_gateway.proto:964-965`, `:978-979`); wire-compat policy headers present in all three files; `gatewayProtocolVersion: 3` / `workerProtocolVersion: 1` in `clients/proto/proto-inputs.json:4-5` match `GatewayContractInfo.cs:12,15` (additive changes correctly did not bump either). `galaxy_repository.proto` is unchanged and still wire-identical to the package source (see IPC-37).
|
||||
|
||||
Generated-tree freshness for the new symbols, grep-verified at HEAD: `Contracts/Generated/MxaccessGateway.cs` (`AlarmSnapshotStatus` ×16, `from_truncated_snapshot` ×2), Go `mxaccess_gateway.pb.go` (×15 / ×3), Python `mxaccess_gateway_pb2.py` (descriptor bytes carry `\x66rom_truncated_snapshot`, `AlarmSnapshotStatus`), Java `MxaccessGateway.java` (×106 / ×7), the committed protoset (×2 / ×2), and the Rust vendored copies `cmp`-identical for all three files. Protos, protoset, and every binding set were last touched in the same commit (`c3c603f`, 2026-08-18) — the regen-everything-together discipline held, including for a comment-only change.
|
||||
|
||||
## Prior-finding verification
|
||||
|
||||
### Cycle-2 findings (IPC-23..32)
|
||||
|
||||
Every tracked status was verified against code read at HEAD. **All eight Done claims hold; one carries a residual filed as a new finding (IPC-23 → IPC-33).**
|
||||
|
||||
| ID | Claimed | Verified? | Evidence (HEAD) | Notes |
|
||||
|----|---------|-----------|-----------------|-------|
|
||||
| IPC-23 | Done (mechanics in WRK-21) | ✅ holds, **with caveat** | Contract half: `GatewayHello.max_frame_bytes` comment extended (`mxaccess_worker.proto:50-52`); `DrainEventsReply` truncation comment (`mxaccess_gateway.proto:702-705`); `docs/WorkerFrameProtocol.md:43-51` states the byte-cap rule and drain-until-empty contract; `gateway.md:565-572` documents both axes. Mechanics: byte-budgeted `Drain(uint, int)` leaves a non-fitting head queued and reports `OversizedHeadSequence` (`Worker/MxAccess/MxAccessEventQueue.cs:389-429`, per-event size memoized at enqueue `:493-509`); reply builder `CreateDrainEventsReply` consumes it with a truncation `DiagnosticMessage` (`Worker/Ipc/WorkerPipeSession.cs:978-1020`); budget = `max(Max − 64 KiB, Max/2)` (`:1034-1039`); shared count ceiling `GatewayContractInfo.MaxDrainEventsPerCommand` (`Contracts/GatewayContractInfo.cs:33`) consumed by both the gateway validator (`Grpc/MxAccessGrpcRequestValidator.cs:81-86`) and the worker clamp (`:992-994`); reply-too-large backstop answers `InvalidRequest` instead of dying (`:847-864`). R1–R3 satisfied. | Caveat: R1 bounds the reply against the **pipe** frame max; the same `MxCommandReply` then crosses the public gRPC boundary whose send cap is `MaxGrpcMessageBytes` = pipe max − 64 KiB, and the budget's own 64 KiB headroom consumes exactly that margin → **IPC-33**. The tracker's rationale for leaving `docs/Grpc.md` without a DrainEvents row ("a worker IPC diagnostic, not a public RPC") is inaccurate — `DrainEvents` is a public `MxCommandKind` (`mxaccess_gateway.proto:136`) reachable through `Invoke` → **IPC-38**. |
|
||||
| IPC-24 | Done | ✅ holds | Churn-revert step deleted; `java` job runs `gradle test` then `git diff --exit-code -- clients/java/src/main/generated` (`.gitea/workflows/ci.yml:135-142` (test step `:135-137`, diff gate `:138-142`), comment at `:113-119` records the rationale); toolchain pinned `grpcVersion 1.76.0` / `protobufVersion 4.33.1` (`clients/java/build.gradle:8,11`); `checkGeneratedClean` revived for Gradle 9 via `providers.exec` evaluated lazily in `doLast` (`clients/java/zb-mom-ww-mxgateway-client/build.gradle:71-92`, `df45cb4`); docs updated (`docs/ClientProtoGeneration.md:97,100-106`). Java aggregate at HEAD carries every new symbol (see above). | The lazy `ProviderFactory.exec` shape is correct: the provider is first realized in `doLast`, after the `generateProto` dependency ran, so the git probe sees the regenerated tree. |
|
||||
| IPC-25 | Done | ✅ holds | Go/Python bindings regenerated and fresh (grep above; `max_frame_bytes` now in `mxaccess_worker.pb.go` ×2 and `mxaccess_worker_pb2.py` ×1); Check 4 regenerates both via the per-client scripts and fails on any `git status --porcelain` diff, with tool-missing → fail not skip (`scripts/check-codegen.ps1:109-129`, header `:19-24`); Go script asserts `protoc-gen-go v1.36.11` / `protoc-gen-go-grpc 1.6.2` exactly and warns-only on protoc (`clients/go/generate-proto.ps1:13-15,63-78`), with the Windows `.exe` banner normalized (`:22-28`, `c94c4d4`); Python script asserts grpcio-tools 1.80.0 with PS5.1-safe quoting (`clients/python/generate-proto.ps1:36-49`, `8ae0c2f`); CI installs the pinned generators before the check (`ci.yml:63-72`); pins table in `docs/ClientProtoGeneration.md:90-98`. | Windows-side fixes landed after the cycle-2 design (Check 4 now runs on windev too). Backslash-joined paths in both scripts are normalized by pwsh on Unix (verified locally with `Test-Path`). |
|
||||
| IPC-26 | Done (mechanics in WRK-22) | ✅ holds | Contract requirement ("cancelled means never written") implemented by claim/tombstone interlock: `TombstoneIfUnclaimed` (`Worker/Ipc/WorkerFrameWriter.cs:181,308-338`), `DequeueNext` skips tombstones (`:588`), NEXT-04 fault observer on every abandoned frame (`:350`); documented with the claimed-mid-write residual at `docs/WorkerFrameProtocol.md:202-224`. Full mechanics verification in `20-worker.md` (WRK-22, NEXT-04/05). | Doc landed in the same batch as the code, as the plan required. |
|
||||
| IPC-27 | Done | ✅ holds | `ClientProtoInputTests.Descriptor_ContainsEveryContractSymbol` collects messages, fields, nested/top-level enums and values, services and methods from the protoset (`Tests/Contracts/ClientProtoInputTests.cs:37-62,108-146`) and walks all three reflection descriptors including `GalaxyRepositoryReflection.Descriptor` (`:65-99`); doc prose updated (`docs/ClientProtoGeneration.md:76-82`). | Presence-only (no field numbers / types), per the recorded design; the protoc-pinned script check remains the byte-level gate. |
|
||||
| IPC-28 | Done | ✅ holds | `docs/Grpc.md:285` lists `CommandTooLarge → ResourceExhausted`; Invoke-section paragraph at `:85` explains the per-correlation failure and the headroom rule; matches `Grpc/MxAccessGatewayService.cs:1056`. | |
|
||||
| IPC-29 | Done (discharged by WRK-26) | ✅ holds | `docs/WorkerFrameProtocol.md:79-224` "Write Scheduling And Sequencing": two-class scheduler, peek-stamp-commit write-time stamping (`:60-64`, `:98-107`), per-frame vs stream failure (`:109-120`), flush coalescing and boundary flush (`:122-172`), unparking (`:174-200`), cancellation tombstones (`:202-224`); plus a new "Pipe Buffers" section (`:226-235`, `da84635`). Each claim cross-checked against `Worker/Ipc/WorkerFrameWriter.cs` anchors cited in `20-worker.md`. | The doc now slightly *exceeds* the code in one place: `mxaccess_worker.proto:50-52` says "reply builders truncate to fit" immediately after listing events — events do not truncate (IPC-30 policy). Readable as intended; no action. |
|
||||
| IPC-30 | Done | ✅ holds | Batch-path `MessageTooLarge` is mapped to the first overshooting envelope (`Worker/Ipc/WorkerPipeSession.cs:633-648`, `FindOversizedEvent :648-660`), logged identity-only, written as a `ProtocolViolation` fault with `CommandMethod = "EventDrain"` (`:713`), then thrown; policy documented at `docs/WorkerFrameProtocol.md:53-58`. | Gateway side needed no change: `DispatchEnvelope` already routes `WorkerFault` to `SetFaulted(WorkerFaulted, …)` (`Server/Workers/WorkerClient.cs:614-618`). |
|
||||
| IPC-31 | N/A (accepted divergence) | **Superseded — resolved by GWC-28** | The gateway now stamps `Sequence` in `WriteLoopAsync` immediately before the write (`Server/Workers/WorkerClient.cs:465-471`), `CreateEnvelope` leaves it unset (`:1229-1236`); `gateway.md:425-431` rewritten to "both sides stamp at the point of writing". Regression test `WorkerClientTests.ConcurrentInvokesEmitStrictlyIncreasingSequencesOnTheWire`. | The two sides now implement the same semantics; flip the IPC-31 row from N/A to Resolved-by-GWC-28 in the tracker. See IPC-39 for the one interaction the change created with the IPC-03 pre-check. |
|
||||
| IPC-32 | Done | ✅ holds | Banners read `Check 1/4` … `Check 4/4` (`scripts/check-codegen.ps1:40,52,78,110`); header comment lists four checks (`:4-24`). | |
|
||||
|
||||
**Done claims that do not fully hold:** none outright. IPC-23 holds for the defect as filed but leaves the gRPC-boundary residual (IPC-33).
|
||||
|
||||
### Cycle-1 findings still open at cycle 2 — re-checked at HEAD
|
||||
|
||||
| ID | Cycle-2 status | HEAD status | Evidence |
|
||||
|----|----------------|-------------|----------|
|
||||
| IPC-08 | Open (`WorkerCancel` defined, handled, never sent) | **Substantially resolved by `7c1ea12`/`a1a38b5` — recommend closing with a residual** | `InvokeAsync` forwards a `WorkerCancel` when the gateway's own command timeout fires and it owned the removal (`Server/Workers/WorkerClient.cs:269-291`); `TrySendCancelForTimedOutCommand` is structurally no-throw (`:835-870`, doc `:806-833`); worker routes it to `CancelCommand` (`Worker/Ipc/WorkerPipeSession.cs:755-756` → `MxAccess/MxAccessStaSession.cs:529-532` → `Sta/StaCommandDispatcher.CancelQueuedCommand :151`); test `WorkerClientTests.InvokeAsync_WhenCommandTimesOut_SendsWorkerCancelForThatCorrelation` (`:185`); `docs/GatewayProcessDesign.md:596-602` documents it. Residual: the caller-cancellation path sends nothing → **IPC-34**. |
|
||||
| IPC-10 | Open (softened) | **Still open, unchanged** | `Server/Workers/WorkerEnvelopeValidator.cs:15-39` and `Worker/Ipc/WorkerEnvelopeValidator.cs` check version/session/body only; `mxaccess_worker.proto:23` `uint64 sequence = 3;` still has no comment. `gateway.md:425-431` continues to state honestly that sequence is a diagnostic aid, and now that both sides stamp at write (GWC-28/WRK-04) the property an inbound check would rely on actually holds. Acceptable state. |
|
||||
| IPC-11 | Open | **Still open, unchanged** | Strict equality on both sides: `Worker/Ipc/WorkerPipeSession.cs:370-375` (`ProtocolVersionMismatch`), `Server/Workers/WorkerClient.cs:913-918`; no min/max-range note on `supported_protocol_version` (`mxaccess_worker.proto:43`). No action was ever required. |
|
||||
| IPC-12 | Open | **Still open, surface unchanged** | `Server/Workers/WorkerFrameWriter.cs:8-11` class doc still has no single-writer remark and the class has no lock; the invariant is now stated at the *call site* instead (`WorkerClient.cs:465-470`: "This loop is the channel's single consumer (SingleReader = true)"), and every outbound envelope — hello `:171`, commands `:251`, cancels `:848-858`, shutdown — funnels through `_outboundEnvelopes`. The gateway reader, by contrast, got its contract written into the class remark when GWC-30 made it stateful (`WorkerFrameReader.cs:11-16`); the writer deserves the same one-paragraph note. |
|
||||
| IPC-16 / IPC-22 | N/A | Unchanged | Correlation id still carried on envelope + inner reply (`gateway.md:432-434` documents envelope as authoritative); status-code-plus-prose error model unchanged. |
|
||||
| IPC-18 | Preserve | **Preserved** | See "Proto evolution" above — `Generated/`, all five client trees, the protoset, and the Rust vendored copies are in sync at HEAD; `galaxy_repository.proto` retention comment intact (`ZB.MOM.WW.MxGateway.Contracts.csproj:36-41`). |
|
||||
|
||||
### NEXT rows touching this domain
|
||||
|
||||
- **NEXT-04 / NEXT-05** (frame-writer abandonment observation; lazy tombstone purge as a documented decision) — both verified in `20-worker.md`; the contract-level statement lives at `docs/WorkerFrameProtocol.md:213-224`. Nothing further from this domain.
|
||||
- **NEXT-01** (pipe name length) — verified in `10-gateway-core.md`; the frame protocol is unaffected (the name is a transport address, not a wire field).
|
||||
|
||||
### Also verified in passing
|
||||
|
||||
- **TST-28** — the gateway half of the `max_frame_bytes` handshake is pinned on the CI-run suite: `WorkerClientTests.StartAsync_SendsGatewayHelloWithConfiguredMaxFrameBytes` (`:46-57`), hello built at `WorkerClient.cs:1172-1184` from `FrameOptions.MaxMessageBytes`.
|
||||
- **Negotiation floor/ceiling agreement** — worker accepts `[1024, 256 MiB]` (`Worker/Ipc/WorkerFrameProtocolOptions.cs:19,30,137-161`); the gateway validator bounds `Worker:MaxMessageBytes` to the identical range (`Configuration/GatewayOptionsValidator.cs:10-11,290-293`) and enforces pipe ≥ gRPC + 64 KiB with `long` arithmetic (`:614-628`). The worker can never reject a value the gateway's validator admits.
|
||||
- **Public gRPC caps are actually applied** — `MaxReceiveMessageSize` **and** `MaxSendMessageSize` are both set from `Protocol:MaxGrpcMessageBytes` (`Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs:36-37`); default 16 MiB (`Configuration/ProtocolOptions.cs:16`). Load-bearing for IPC-33.
|
||||
- **Check 3 both directions** (`711793f`) — a canonical proto without a vendored Rust copy now fails too (`scripts/check-codegen.ps1:95-103`), closing the `build.rs`-prefers-canonical blind spot its comment describes.
|
||||
- **Contracts package version** bumped to 0.2.0 with the CLI-39 release train (`ZB.MOM.WW.MxGateway.Contracts.csproj:10`); `Grpc.Tools` 2.80.0 / `Google.Protobuf` 3.34.1 pins unchanged.
|
||||
|
||||
## New findings
|
||||
|
||||
IDs continue from the register (cycle 2 ended at IPC-32). No `IPC-33+` label exists anywhere in the repo or its history (grep), so there is no collision of the GWC-31 kind here.
|
||||
|
||||
### IPC-33 — The DrainEvents byte budget is sized against the pipe frame maximum, not the public gRPC send cap; the reply can cross the pipe and then fail at the gRPC boundary with the drained events already consumed
|
||||
|
||||
- **Severity:** Low
|
||||
- **Files:** `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs:31` (`DrainReplyFrameHeadroomBytes = 64 KiB`), `:1034-1039` (`ResolveDrainReplyByteBudget` = `max(MaxMessageBytes − 64 KiB, MaxMessageBytes / 2)`); `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventQueue.cs:389-429` (events dequeued as they are packed); `src/ZB.MOM.WW.MxGateway.Server/Configuration/WorkerOptions.cs:57-66` (pipe default = gRPC 16 MiB + 64 KiB reserve); `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:614-628` (pipe ≥ gRPC + reserve, no upper coupling); `src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs:37` (`MaxSendMessageSize = MaxGrpcMessageBytes`); `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto:20` (`Invoke` returns the bare `MxCommandReply`).
|
||||
- **Description:** WRK-21/IPC-23 made the reply fit the *pipe* (R1), but the same `MxCommandReply` is then returned verbatim through `Invoke`, where the gateway's gRPC `MaxSendMessageSize` applies. The two limits are coupled only from below: the validator requires pipe ≥ gRPC + 64 KiB, and the drain budget subtracts the same 64 KiB — so at default configuration the event bytes are budgeted to exactly `MaxGrpcMessageBytes` (16 MiB), leaving the `MxCommandReply` wrapper (34-byte correlation id, protocol status, kind, the `drain_events` length prefix, and — precisely in the truncated case — the `DiagnosticMessage`) with zero margin. The per-event `RepeatedFieldOverheadBytes` slack (+8 per event) absorbs this whenever the packed batch has more than ~30 events, so the default-config overshoot needs a backlog of ≥ 16 MiB composed of a few very large (≥ ~500 KiB) array events — narrow but real. The coupling is entirely absent for the configurations the validator explicitly permits: `Worker:MaxMessageBytes` may be anything up to 256 MiB while `Protocol:MaxGrpcMessageBytes` stays 16 MiB, and then every `DrainEvents` against a backlog above 16 MiB builds a reply of up to `pipeMax − 64 KiB`, which the worker writes, the pipe carries, the gateway parses — and the gRPC layer refuses to send (`ResourceExhausted: Sending message exceeds the maximum configured message size`). At that point the events are already dequeued from the worker queue (`Drain` removes what it packs), so up to 10 000 events are lost to both the diagnostic caller and the event stream, and the caller sees only a failed `Invoke`. This is the IPC-23 invariant ("no diagnostics command may kill a session") satisfied, and its sibling ("no event loss on truncation", R2) violated one boundary later. The same asymmetry exists for single events in the 64 KiB window between the gRPC cap and the pipe cap (an event the pipe accepts but `StreamEvents` cannot send), but a single ≥ 16 MiB event is pathological and already fails loudly on the stream; it is noted, not filed.
|
||||
- **Recommendation:** Bound the drain against the *smaller* of the two boundaries. Cheapest correct fix without a wire change: the gateway knows both caps, so either (a) add an upper coupling in `GatewayOptionsValidator` (`Worker:MaxMessageBytes ≤ MaxGrpcMessageBytes + 2 × reserve`, or simply document and validate the equality the default already has) and change the worker budget to `MaxMessageBytes − 2 × reserve` so the reply wrapper has a full reserve below the gRPC cap; or (b) convey `MaxGrpcMessageBytes` in `GatewayHello` (additive `uint32`, same zero-means-unknown convention as `max_frame_bytes`) and budget against `min(pipe − reserve, grpc − reserve)`. Add a gateway fake-worker test that drains a reply packed to the budget through the real gRPC pipeline (the TST-24 wire-test harness can host it) so the boundary is exercised end to end.
|
||||
|
||||
### IPC-34 — `WorkerCancel` is forwarded only when the gateway's own command timeout fires; a caller-cancelled `Invoke` (client deadline, disconnect) leaves the command queued on the worker's STA
|
||||
|
||||
- **Severity:** Low
|
||||
- **Files:** `src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs:269-291` (timeout arm → `TrySendCancelForTimedOutCommand`), `:294-305` (`OperationCanceledException` arm: `RemovePendingCommandAsFailed(GatewayShutdown, "Command wait was canceled.")`, no cancel sent), `:307-318` (timer-won-but-token-cancelled arm, same); `src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs:138` (`context.CancellationToken` flows into `InvokeAsync`); `src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs:1100`; worker side `src/ZB.MOM.WW.MxGateway.Worker/Sta/StaCommandDispatcher.cs:151-185` (`CancelQueuedCommand` drops only not-yet-started entries); `docs/GatewayProcessDesign.md:596-602`; `gateway.md:913-919`.
|
||||
- **Description:** The IPC-08 closure's own rationale — "the gateway has stopped waiting, but the worker has not stopped working … a queued-but-not-started command is dropped instead of occupying the STA behind a caller that is already gone" (`WorkerClient.cs:279-284`) — applies identically when the caller's token fires, and that is the *common* abandonment path: gRPC clients routinely set per-call deadlines shorter than the gateway's `CommandTimeout`, and a client disconnect cancels `context.CancellationToken`. Both land in the `OperationCanceledException` arm, which removes the pending entry and returns, so the correlation stays on the single STA queue and executes when its turn comes. Under a burst of deadline-cancelled calls (a client retry storm against a slow provider) the STA works through dead commands while live callers queue behind them — exactly the backlog the cancel was added to shorten. The gateway-side design text still frames cancellation as "stop waiting" only (`gateway.md:915`), and `docs/GatewayProcessDesign.md:596` says "on timeout", so the asymmetry is documented-by-omission rather than decided. Secondary: the arm labels caller cancellation `WorkerClientErrorCode.GatewayShutdown` (pre-existing; maps to `Cancelled` at the gRPC layer per `docs/Grpc.md:285`, so the client sees the right status, but metrics/logs record a shutdown that did not happen).
|
||||
- **Recommendation:** Call the same no-throw `TrySendCancelForTimedOutCommand` (renamed to `TrySendCancelForAbandonedCommand`, reason "caller cancelled") from the caller-cancellation arms, gated on the same `removed` ownership check so a reply that won the race is never chased. Skip it only when `_stopCts` is the source of the cancellation (the channel is being completed anyway). Add a `WorkerClientTests` twin of the timeout test driven by the caller token. Separately, introduce a `CallerCancelled` error code (or reuse `CommandTimeout` semantics) so the metric label stops saying `GatewayShutdown`; update the `docs/Grpc.md:285` mapping line in the same change.
|
||||
|
||||
### IPC-35 — A `WorkerFault` the worker emits during the startup handshake is flattened by the gateway into a generic `ProtocolViolation`, discarding the structured fault category and diagnostic message
|
||||
|
||||
- **Severity:** Low
|
||||
- **Files:** `src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs:894-907` (`ReadHandshakeEnvelopeAsync`: any body other than the expected one → `WorkerClientException(ProtocolViolation, "Worker handshake expected {expected} but received {actual}")`), `:164-185` (`StartAsync` expects `WorkerHello` then `WorkerReady`); worker side `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs:335-357` (`CompleteStartupHandshakeAsync` writes a `WorkerFault` for every `WorkerFrameProtocolException` and for `MxAccessCreationException` — COM creation failure, the most likely real-world startup fault — before rethrowing), `:1392-1447` (`TryWriteFaultAsync` overloads); `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs:132-160` (the exception message is what reaches `MarkFaulted`, the warning log, and the `SessionManagerException` inner).
|
||||
- **Description:** The worker deliberately reports startup failures as structured frames — `MxAccessCreationException.From(...)` carries the COM HRESULT/ProgID context and `WorkerFaultCategory` — so the gateway can tell "MXAccess is not installed / not registered / access denied" apart from a pipe-level protocol problem. The gateway's handshake reader does not look inside: it sees `BodyCase == WorkerFault` where it expected `WorkerReady`, throws a `ProtocolViolation` whose message names only the body type, and `SessionManager.OpenSessionAsync` surfaces that text to the operator log and to the client's `OpenFailed`. The post-handshake path does this correctly (`DispatchEnvelope` → `SetFaulted(WorkerFaulted, CreateWorkerFaultMessage(fault))`, `WorkerClient.cs:614-618`), so the information loss is confined to the window in which it matters most — first session open on a freshly deployed host, where the answer is almost always an MXAccess installation or permission problem, and the operator is pointed at "protocol violation" instead. The `FakeWorkerHarness` only ever scripts well-formed handshakes (`Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs:140-200`), so nothing pins the behaviour either way.
|
||||
- **Recommendation:** In `ReadHandshakeEnvelopeAsync`, special-case `BodyCase == WorkerFault`: throw `WorkerClientException(WorkerFaulted, CreateWorkerFaultMessage(envelope.WorkerFault))` (the same formatter the steady-state path uses) so category and diagnostic reach the log and the `OpenFailed` detail. Keep `ProtocolViolation` for every other unexpected body. Add a `WorkerClientTests` case where the fake writes a `WorkerFault{Category = MxaccessCreationFailed}` in place of `WorkerReady` and asserts the category text surfaces.
|
||||
|
||||
### IPC-36 — The worker launch environment has become a second, undeclared gateway↔worker contract: six variable names duplicated as independent literals on both sides, four of them new since baseline, two documented
|
||||
|
||||
- **Severity:** Low
|
||||
- **Files:** gateway literals `src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs:18-52` (`MXGATEWAY_WORKER_NONCE`, `…_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS`, `…_WRITE_COMPLETION_WAIT_MS`, `MXGATEWAY_ALARM_POLL_INTERVAL_MS`, `MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH`, `MXGATEWAY_EVENT_QUEUE_CAPACITY`), set at `:207-217`; worker literals `src/ZB.MOM.WW.MxGateway.Worker/Bootstrap/WorkerOptions.cs`, `Ipc/WorkerPipeClient.cs`, `MxAccess/MxAccessStaSession.cs:14-24`, `MxAccess/MxAccessEventQueue.cs`, `MxAccess/WnWrapAlarmConsumer.cs` (one copy each); the one precedent that *is* shared: `src/ZB.MOM.WW.MxGateway.Contracts/GatewayContractInfo.cs:35-43` (`LiveMxAccessOptInVariableName`, shared "so any future opt-in tweak does not silently leave one project behind"); docs `docs/WorkerProcessLauncher.md:25-27,47-51` (nonce + connect timeout only), `docs/MxAccessWorkerInstanceDesign.md:93-98` ("Expected protected environment values": nonce + log context only); public proto comment `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto:540` names `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS`.
|
||||
- **Description:** Since `4f5371f`, four runtime knobs were plumbed to the worker through `ProcessStartInfo.Environment` rather than through `GatewayHello` — write-completion wait, alarm poll cadence, alarm fetch cap, event-queue capacity. Environment conveyance is a reasonable choice (the values are needed before or independent of the handshake, and the worker's fallback-to-default on a missing variable keeps old/new pairs working), but it makes the launch environment a wire contract in everything but name, and it is treated with none of the discipline the proto contract gets: each name exists as two unrelated string literals in two projects (a typo on either side silently reverts that knob to its default — the worker's documented "missing or unparseable → default" fallback turns a contract break into a no-op), the Contracts project already demonstrates the right home for exactly this kind of name and was not used, and the documentation that should enumerate the contract lists two of the six. The public `mxaccess_gateway.proto` comment additionally exposes the internal env-var name to every language-client consumer, where the operator-facing key is `MxGateway:Worker:WriteCompletionWaitMilliseconds` (the `WorkerOptions.cs:28-34` and launcher `:25-27` doc comments are also stale — they still say "WriteSecured/WriteSecured2" although `b0e65d4` extended the wait to plain `Write`/`Write2`).
|
||||
- **Recommendation:** Move the six names into a `WorkerLaunchEnvironment` static class in `ZB.MOM.WW.MxGateway.Contracts` (net48-safe `const string`s, like `GatewayContractInfo`) and reference it from both sides; add a one-table "Launch environment" section to `docs/WorkerProcessLauncher.md` (variable, source config key, worker default on absence) and point `docs/MxAccessWorkerInstanceDesign.md:93-98` at it; replace the env-var name in the proto comment with the config key; refresh the two "WriteSecured-only" comments. A small test asserting the launcher sets every constant in the class keeps the table and the code from drifting.
|
||||
|
||||
### IPC-37 — The `galaxy_repository.proto` wire-identity with the `ZB.MOM.WW.GalaxyRepository` package is still unguarded, although both descriptors are now loadable in one test process
|
||||
|
||||
- **Severity:** Low
|
||||
- **Files:** `src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj:36-41` (retention rationale: the proto is the codegen source for all five clients and must stay wire-identical to the package); `src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj:23` (`ZB.MOM.WW.GalaxyRepository` 0.2.0 — the server serves the package's implementation); `src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientProtoInputTests.cs:65-70` (walks `GalaxyRepositoryReflection.Descriptor` from *Contracts* only); package proto `~/Desktop/scadaproj/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/Protos/galaxy_repository.proto` (last changed `5572edd`, 2026-06-23).
|
||||
- **Description:** Verified at HEAD: the two files differ only in `option csharp_namespace` (comment-stripped `diff`), so the invariant holds today. Nothing keeps it true. The package version moves with the whole `ZB.MOM.WW.*` family (repo memory: a version jump is not evidence the package changed), so a routine `PackageReference` bump can bring a wire change in `galaxy_repository.v1` — a new field, a renamed RPC, a different streaming shape — that the server starts serving while the five clients keep generating from the retained, now-stale copy. Check 1–4 and the descriptor test would all stay green, because they compare the clients against the *retained* proto, never the retained proto against the *served* one. Cycle 1 recorded this as an optional test (`archreview/remediation/30-contracts-ipc.md:340-344`) when it would have required a cross-repo path; that is no longer the case — the test project transitively loads the package through the server reference (`Tests/Configuration/GalaxyRepositoryOptionsValidatorTests.cs` and four other test files already `using ZB.MOM.WW.GalaxyRepository`), so `ZB.MOM.WW.GalaxyRepository.Grpc.GalaxyRepositoryReflection.Descriptor` and `ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor` are both in-process.
|
||||
- **Recommendation:** Add `GalaxyContractParityTests`: take `FileDescriptor.ToProto()` from both, clear `SourceCodeInfo` and `Options.CsharpNamespace` (and `Name` if the paths differ), and assert byte-equality of the serialized `FileDescriptorProto`. One test, no protoc, runs in the portable CI job, and turns a silent package-bump drift into a red build with the exact differing symbol named. Record the new guard in the csproj retention comment and `docs/Contracts.md`.
|
||||
|
||||
### IPC-38 — `docs/Grpc.md` has no coverage of `DrainEvents` although it is a public `MxCommandKind` reachable through `Invoke`; the tracker's reason for omitting it is inaccurate
|
||||
|
||||
- **Severity:** Info
|
||||
- **Files:** `docs/Grpc.md` (grep for `DrainEvents` → 0 hits); `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto:136` (`DrainEventsCommand drain_events = 103` in the public `MxCommand` oneof), `:520-523`, `:701-706`; `src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs:74-88` (public-boundary validation of `max_events`); `archreview/2026-07-12/remediation/00-tracking.md:87` ("`docs/Grpc.md` intentionally carries no DrainEvents row since it scopes to the seven public gRPC RPCs and DrainEvents is a worker IPC diagnostic"); `gateway.md:565-572` (does document it).
|
||||
- **Description:** `DrainEvents` is not an IPC-only diagnostic: any client with `invoke:read` can issue it through `Invoke`, the public validator has a rule for it, and the cycle-2 plan (IPC-23 step 5) asked for a rules-table note because the reply is now truncated on two axes with a drain-until-empty contract that callers must know. The tracker closed that step on a premise the proto contradicts. `gateway.md` carries the prose, so the information exists; `docs/Grpc.md` is the document the client READMEs and the rules table point readers at for per-command behaviour, and it is silent.
|
||||
- **Recommendation:** Add a `DrainEvents` row to the rules table in `docs/Grpc.md` (count cap, byte cap, `DiagnosticMessage` truncation marker, drain-until-empty, and that drained events are removed from the session's event stream), cross-linking `gateway.md:565`. Correct the tracker note.
|
||||
|
||||
### IPC-39 — The IPC-03 `CommandTooLarge` pre-check now measures the envelope before GWC-28 stamps the sequence, leaving an ≤ 11-byte window in which the writer's `MessageTooLarge` is still session-fatal; the explanatory comment has the direction of conservatism backwards for the pre-check itself
|
||||
|
||||
- **Severity:** Info
|
||||
- **Files:** `src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs:236-249` (pre-check: `CalculateSize()` on an envelope whose `Sequence` is still 0 → the `sequence` field is not encoded), `:465-471` (`WriteLoopAsync` stamps `Sequence` → up to 1 tag + 10 varint bytes added), `src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerFrameWriter.cs:42-55` (post-stamp `CalculateSize()` > max → `MessageTooLarge`), `:478-483` (write-loop exception → `SetFaulted(WriteFailed)`, session-fatal).
|
||||
- **Description:** The comment at `:236-241` correctly explains why the pre-check size cannot be reused as the writer's `knownSize` ("a lower bound … conservative in the right direction") — true for that purpose, but the same fact inverts for the pre-check's own purpose: a lower bound admits envelopes the writer will reject. An envelope in `(MaxMessageBytes − 11, MaxMessageBytes]` passes `:243` and faults the session at the write. Reachability is nil under the shipped validator: every caller-controlled command arrives through gRPC capped at `MaxGrpcMessageBytes`, which is ≥ 64 KiB below the pipe max, and gateway-authored commands are tiny — so this is a correctness-of-argument note, not a live defect. It is worth recording because the headroom rule is the only thing making it unreachable, and the comment currently reads as if the pre-check were exact.
|
||||
- **Recommendation:** Either compare against `MaxMessageBytes − MaxSequenceEncodingBytes` (11) in the pre-check, or reword the comment to say the pre-check is a lower bound that relies on the 64 KiB reserve for its safety margin. No test needed beyond the existing `CommandTooLarge` case.
|
||||
|
||||
## Severity roll-up (new findings)
|
||||
|
||||
| Severity | Count | IDs |
|
||||
|----------|:-:|-----|
|
||||
| Critical | 0 | — |
|
||||
| High | 0 | — |
|
||||
| Medium | 0 | — |
|
||||
| Low | 5 | IPC-33, IPC-34, IPC-35, IPC-36, IPC-37 |
|
||||
| Info | 2 | IPC-38, IPC-39 |
|
||||
|
||||
Prior-finding verification: 8/8 cycle-2 Done claims hold (IPC-23 with the IPC-33 residual); IPC-31 superseded by GWC-28 (recommend flipping to Resolved); cycle-1 IPC-08 substantially resolved by `7c1ea12` (recommend closing, residual tracked as IPC-34); IPC-10/11/12 confirmed still open, none regressed.
|
||||
|
||||
## Still good / preserve under change
|
||||
|
||||
The contract boundary is in the best shape it has been across three cycles. Proto evolution since baseline was purely additive with correct next-free tags and reserved ranges intact, and — for the first time — every generated artifact (C# `Generated/`, Go, Python, Java, Rust vendored copies, the protoset) moved in the *same commit* as the proto, including for a comment-only edit; the freshness net that makes this checkable is now genuinely closed (Checks 1–4 + the reflection test + the Java `git diff` gate, all in CI, all tool-missing-fails-not-skips, all with their generator pins asserted rather than assumed). The frame protocol core is unchanged where it should be — length validated before allocation on both readers, exact-read loops with typed `EndOfStream`, pooled buffers with sound lifetimes on all four codec halves, single-serialization single-write framing — and materially stronger where it changed: both sides now stamp `sequence` at the point of writing so the documented "monotonic per sender" contract is finally true on both ends, the negotiated `max_frame_bytes` handshake has matching `[1024, 256 MiB]` bounds on both sides so a legal gateway configuration can never be refused by the worker, the DrainEvents reply is bounded on count *and* bytes with no event loss at the pipe, oversized events die structured and named, and cancelled writes are guaranteed never to reach the wire. `WorkerCancel` is no longer a dead arm. `docs/WorkerFrameProtocol.md` is now a faithful, claim-by-claim description of the shipped writer, and `gateway.md`'s envelope section points at the proto as the single source of truth instead of carrying a drifting copy. Preserve in particular: the per-client generator pins and the "tool missing = check fails" rule (a skipped guard is the silent-drift hole the whole P0 codegen batch existed to close); the pipe-over-gRPC 64 KiB reserve invariant in the validator (IPC-33 and IPC-39 both rest on it); the habit of landing proto-comment changes with the full regen wave; and the csproj retention comment on `galaxy_repository.proto` — until IPC-37's test exists, that comment is the only thing standing between a package bump and a five-client wire drift.
|
||||
|
||||
## Verification needed on Windows (windev)
|
||||
|
||||
- This review is static. The worker-side halves cited here (byte-budgeted drain, IPC-30 structured death, tombstones, the floor/ceiling handshake rejection) are exercised only by `Worker.Tests -p:Platform=x86`; the tracker and repo memory record the suite fully green on windev after the harness fixes, which this review did not re-run.
|
||||
- IPC-33's end-to-end shape (reply built at budget → `Invoke` send failure) cannot be reproduced with the fake worker alone on macOS if the fix lands worker-side; the validator/gateway-side variant (option a) is portable and should get a `GatewayOptionsTests` case plus a TST-24-style wire test.
|
||||
- IPC-35's regression test is portable (fake worker writes a handshake-phase `WorkerFault`); no windev run needed.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Security & Dashboard — Re-review (Cycle 3)
|
||||
|
||||
**Date:** 2026-08-19 · **Commit:** `8b3e3b3` (`main`, clean) · **Baseline:** `4f5371f` (2026-07-12 cycle) · **Method:** static review on the macOS working tree (no builds, no runtime probes, no source modifications). Every claim cites a `path:line` read at HEAD. Prior findings are the `SEC-NN` IDs from `archreview/remediation/40-security-dashboard.md` (cycle 1) and `archreview/2026-07-12/40-security-dashboard.md` / `archreview/2026-07-12/remediation/40-security-dashboard.md` (cycle 2); new findings continue from SEC-37. This domain absorbed the most new machinery since the baseline (~55 of ~247 commits touched Security/Dashboard/Configuration): the SEC-31/32 failure-limiter re-partition, SEC-33/34/36 remediations, the SEC-25 tag-ACL epic (`DashboardTags` constraints, session tag inheritance, `GroupToTag`/`UntaggedSessionVisibility`, events-hub + in-process ACL), the `ShowTagValues` three-seam closure, the secrets-store adoption (`${secret:}` expansion, `/admin/secrets`), the bounded async audit writer with retention, and the snapshot-feed/alarm-page reliability rework.
|
||||
|
||||
## Prior-finding verification
|
||||
|
||||
### Cycle-2 findings (SEC-31…SEC-36)
|
||||
|
||||
| ID | Claimed | Verified? | Evidence (file:line at HEAD) | Notes |
|
||||
|----|---------|-----------|------------------------------|-------|
|
||||
| SEC-31 | Done | **Yes** | Composite `(transport peer, key id)` partition + cross-peer aggregate + probe admission, exactly per the remediation design: `Security/Authorization/ApiKeyFailureLimiter.cs:14-27` (two-layer remarks), `:143-204` (`Check` with probe claim/compensation), `:206-235` (`RecordFailure` into both layers), `:237-276` (`Reset` reachable-while-throttled); interceptor threads the partition and reuses the check-time resolution (`GatewayGrpcAuthorizationInterceptor.cs:83-115`, `:133-137`); new knobs validated (`SecurityOptions.ApiKeyFailureAggregateLimit`/`ApiKeyFailureProbeIntervalSeconds`, wired at `ApiKeyFailureLimiter.cs:74-83`); throttle metric tagged by stage only (`Metrics/GatewayMetrics.cs:350-356`, with an explicit SEC-14/SEC-20 cardinality comment). `ApiKeyFailureLimiterTests.cs` exists. The lockout inversion is structurally closed: a correct secret always reaches the verifier via a probe slot and resets both layers. | Probe-claim compensation is version-stamped (`:341-383`), closing the same-tick stomp the follow-up commits (`5b681ee`, `acebe18`) addressed. Two new residual corners found in the rework — see **SEC-38** (aggregate-layer bypass via the per-peer key-id cap) and **SEC-39** (null==null fallback-partition reset). Neither reopens the SEC-31 lockout: they weaken bounds, they do not deny the legitimate holder. |
|
||||
| SEC-32 | Done | **Yes** | Token shape checked before a key-id partition is minted: `GatewayGrpcAuthorizationInterceptor.cs:149-193` (`TryResolveKeyId` — `mxgw` must be the entire first segment, key id ≤ 64 chars, non-empty third segment; internal for direct test pinning). Per-peer key-id cap `MaxKeyIdPartitionsPerPeer = 32` with overflow collapsing to the address's fallback partition (`ApiKeyFailureLimiter.cs:48-53`, `:393-444`). Eviction never removes load-bearing state below a 2× ceiling and prefers expired windows (`:479-548`). | The junk-token flush is closed as designed. The cap's overflow-collapse rule has a side effect the design did not anticipate — see **SEC-38**. |
|
||||
| SEC-33 | Done | **Yes** | `IsRootedForAnyPlatform` is gone; rooting is host-meaningful `Path.IsPathRooted` in the shared `Configuration/GatewayConfigPathRules.cs:14-39` (used by both validators, with the SEC-01/SEC-33 mechanism named in the doc-comment). `appsettings.json` no longer carries either Windows literal (Authentication section `:20-24` has no `SqlitePath`; Galaxy section `:79-84` has no `SnapshotCachePath`). Gateway-side Galaxy default seeded pre-bind (`GatewayApplication.cs:156-168` — the init-only-property deviation is documented in place) and validated by the new `Configuration/GalaxyRepositoryOptionsValidator.cs:44-72` registered with `ValidateOnStart` (`GatewayApplication.cs:172-176`). No stray `C:*` artifact under `src/` (`find`-verified). | Went beyond the finding: `882c7ca` added `AddIfUnderContentRoot` (`GatewayConfigPathRules.cs:93-124`) after a real 2026-08-09 production key-loss incident (store inside the renamed app directory), and the secrets store got the same absolute default treatment (`GatewayApplication.cs:201-239`). |
|
||||
| SEC-34 | Done | **Yes** | Invalidate race closed with the per-key generation counter: bump-before-evict (`Security/Authentication/CachingApiKeyVerifier.cs:181-199`), snapshot-before-inner + set-then-recheck with self-evict (`:128-141`, `:147-178`); `TryParseKeyId` correctness argument (full key id, `_` forbidden by both key-creation paths) documented at `:205-218`. Expiry window took the documented fallback per the recorded outcome: the library verification identity carries no `ExpiresUtc`, so the ≤ TTL expiry window is documented in the remarks (`:49-60`) with the donor-library ask. | Matches the remediation outcome exactly, including the deviation. Cache-key design unchanged (SHA-256 of the token, success-only). |
|
||||
| SEC-35 | N/A (doc-only) | **Yes** | `docs/GatewayConfiguration.md:282` — the "hard-stops key on the exact environment name" contract is documented (both guards fire only on `IHostEnvironment.IsProduction()`). Validator unchanged: `GatewayOptionsValidator.cs:38`, `:211-214` (LDAP), `:387-391` (DisableLogin). | Note the consequence is now live: windev runs as `Staging` (NEXT-07 relabel), so neither guard applies there — that is the posture gap promoted as **SEC-37**. |
|
||||
| SEC-36 | Done | **Yes (repo side + rotation executed)** | `appsettings.json:33` ships `"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}"` — a fail-closed encrypted-store reference expanded pre-host (`GatewayApplication.cs:75-106`), not a literal. `git grep` for the retired value returns nothing in tracked files. `glauth.md` samples use placeholders; `docs/runbooks/SEC-36-ldap-credential-rotation.md` records the 2026-08-07 rotation (GLAuth `passsha256` replaced in `scadaproj` commit `aada53b`, instance recreated, dashboard `/login` verified through the real `DashboardAuthenticator` path post-NEXT-07). `<UserSecretsId>mxaccessgw-server</UserSecretsId>` present in the csproj; the blank-password validator message names both channels. | The load-bearing half (rotation) is done: the historically recoverable value no longer binds `dc=zb,dc=local`. Fully closed. |
|
||||
|
||||
### Cycle-1 residuals (still-open SEC-13…SEC-27 era)
|
||||
|
||||
| ID | Prior status | At HEAD | Evidence | Notes |
|
||||
|----|--------------|---------|----------|-------|
|
||||
| SEC-13 | Open | **Open, impact reduced** | `Diagnostics/GatewayLogRedactor.cs:11-17` still lists only `AuthenticateUser`/`WriteSecured`/`WriteSecured2` — the `WriteSecuredBulk`/`WriteSecured2Bulk` command kinds (`Grpc/MxAccessGatewayService.cs:364-381`) are still missing. However the redactor is no longer latent: `GatewayLogRedactorSeam` is registered as the telemetry `ILogRedactor` (`GatewayApplication.cs:139`) and masks **every** non-null `CommandValue` because the seam exposes no value-logging opt-in (`Diagnostics/GatewayLogRedactorSeam.cs:38-56`; honestly documented at `docs/Diagnostics.md:120`). The Bulk omission only bites if a future opt-in knob passes `valueLoggingEnabled: true`. | Fix the two missing names when (if) the opt-in knob lands; until then fail-closed. |
|
||||
| SEC-14 | Open | **Confirmed open** | `GatewayApplication.cs:347-348` — `MapZbHealth()`/`MapZbMetrics()` with no `RequireAuthorization`. | Mitigating discipline held: the new throttle counter deliberately carries no key/peer tags because "/metrics is not authenticated (open SEC-14)" (`GatewayMetrics.cs:352-354`). Note `/health/active` has off-repo consumers (family Overview dashboard), so any auth change needs cross-repo coordination. |
|
||||
| SEC-15 | Open (accepted) | **Confirmed open** | GET `/logout` still skips antiforgery, intentional (`Dashboard/DashboardEndpointRouteBuilderExtensions.cs:92-99`). | Accepted risk; unchanged. |
|
||||
| SEC-16 | Open | **Confirmed open** | `Program.cs:37-39` still maps `command.Pepper` → `MxGateway:ApiKeyPepper` (pepper on the CLI command line). | |
|
||||
| SEC-17 | Open | **Confirmed open** | `GatewayGrpcAuthorizationInterceptor.cs:27-54` overrides only unary + server-streaming. All current RPCs remain those shapes. | |
|
||||
| SEC-18 | Open | **Confirmed open** | `Dashboard/DashboardApiKeyManagementService.cs:22,289` — `"pepper unavailable"` message-substring match. | |
|
||||
| SEC-19 | Open | **Resolved** (this period) | `Security/Audit/SqliteCanonicalAuditStore.cs:23-31` — the `CREATE TABLE IF NOT EXISTS` is latched: `AuditDrainService` runs `EnsureInitializedAsync` once at startup and later operations skip the DDL round-trip (self-bootstrap kept for the CLI/DI-free paths; the latch is deliberately racy-idempotent). Landed with the `e2ac5d1`/`7b2d046` audit rework (bounded async writer, batched inserts). | |
|
||||
| SEC-21 | Open | **Resolved** (this period) | `Dashboard/DashboardSnapshotService.cs:25,38,42,287-310` — API-key summaries refresh at most every 15 s behind a zero-timeout gate with a 2 s store timeout, and the snapshot tick itself is idle-gated (`77c5731`, `DashboardSnapshotFeed` idle gate). The per-tick unconditional store read is gone. | |
|
||||
| SEC-23 | Open | **Confirmed open** | `GatewayOptionsValidator.ValidateDashboard` (`:380-455`) gained GroupToRole/GroupToTag/UntaggedSessionVisibility shape checks and the Production `DisableLogin` stop, but still ignores `AutoLoginUser`, `RequireHttpsCookie`, and `CookieName` — an explicit `CookieName` starting `__Host-` with `RequireHttpsCookie=false` is still applied unconditionally (`DashboardServiceCollectionExtensions.cs:154-158`) with no validator diagnosis. | The runtime guard against *deriving* `__Host-` without Secure is correct (`:159-162`); only the explicit-override consistency check is missing. |
|
||||
| SEC-24 | Open | **Confirmed open (narrowed)** | `Configuration/GatewayConfigurationProvider.cs:58-74` / `EffectiveDashboardConfiguration.cs:3-12` — the projection gained `ShowTagValues`, `GroupToRole`, `GroupToTag` (with comparer propagation, `ff16c69`), and `UntaggedSessionVisibility`, and the settings page renders them (`SettingsPage.razor:53-104`); it still omits `DisableLogin`/`AutoLoginUser`/`RequireHttpsCookie`/`CookieName` and the `MxGateway:Security` limiter keys the SEC-31 design asked to absorb here. Secrets stay redacted (`:20,30`). | |
|
||||
| SEC-26 | Open | **Resolved** (this period) | Retention sweep exists: `SqliteCanonicalAuditStore.cs:172` (`DELETE FROM audit_event` with a documented NULL-timestamp keep rule at `:251-253`), driven by `SecurityOptions.AuditRetentionDays` (default 90, `SecurityOptions.cs:96-99`) from the audit drain service. | |
|
||||
| SEC-27 | Open | **Confirmed open** | `DashboardSnapshotService.cs:17,114` — `GatewayStatus` is still the hardcoded `"Healthy"` constant. | |
|
||||
|
||||
### SEC-25 epic verification (the tag-ACL machinery is real and fail-closed)
|
||||
|
||||
Not a table row above because cycle 2 closed SEC-25 as "mitigation, ACL deferred"; the deferred ACL has now shipped and was reviewed as new code:
|
||||
|
||||
- **Tags come from the owning key, never the request:** `Grpc/MxAccessGatewayService.cs:36-46` (owner identity from `identityAccessor`, `owner?.EffectiveConstraints.DashboardTags` into `OpenSessionAsync`); `Sessions/GatewaySession.cs:207-212,270` (immutable ordinal-ignore-case `FrozenSet`); `Security/Authentication/ApiKeyConstraints.cs:35-44` (defensive copy on init; rides the constraints blob to avoid a schema migration; explicitly visibility-only, never data-access).
|
||||
- **ACL decision table:** `Dashboard/DashboardSessionAcl.cs:50-84` — Admin (authenticated) bypass; phantom session denied; untagged session follows `UntaggedSessionVisibility` (default `AdminOnly`, fail-closed); otherwise ordinal-ignore-case tag intersection over `DashboardTagClaimType` claims; `null` principal denied outright, even under `AllViewers` (`IDashboardSessionAcl.cs:25-32`). Anonymous localhost is an empty-grant Viewer.
|
||||
- **Both subscribe seams gated identically:** `Dashboard/Hubs/EventsHub.cs:59-79` (ACL before group join, registry-before-group ordering, opaque `HubException`), and the session-details in-process seam `SessionDetailsPage.razor:321-361` (ACL before any field write or `Subscribe`). The `7ec0b35` re-entrancy fix is sound: the attach generation is claimed synchronously before the first await (`:217-233`), a stale continuation returns without attaching or detaching (`:340-343`), and the pump's `ReferenceEquals` batch guard drops cross-session batches on the dispatcher (`:383-407`).
|
||||
- **Claims plumbing:** tags stamped at login (`DashboardAuthenticator.cs:114-158`) and re-resolved from group claims at hub-token mint so a token bounds grant staleness to 5 minutes (`HubTokenService.cs:80-98`); token `Validate` still rejects identity-less payloads (`:118-126`) and rehydrates tag claims for the hub-path ACL (`:139-145`).
|
||||
- **Owner-scoped gRPC attach (the threat-model anchor) holds:** `Grpc/EventStreamService.cs:52-63` — reattach is `PermissionDenied` unless the caller's key id ordinal-equals the session owner; null owner matches only a null caller.
|
||||
- **Redaction (second, independent layer) covers all three value seams:** events-hub mirror deep-clone (`Dashboard/Hubs/DashboardEventBroadcaster.cs:91,247-259` — top-level `value` plus alarm `CurrentValue`/`LimitValue`, source never mutated), alarms hub deep-clone on both value-bearing arms (`AlarmsHubPublisher.cs:59,103-126` — the cycle-2 closeout's named "alarms-hub redaction gap" residual, closed by `eff17d1`/TST-16 on 2026-08-17), and `/browse` live values at the service boundary with the `'-'`-placeholder-on-failed-reads fix (`DashboardLiveDataService.cs:132-140`; `1ea6f60`). `docs/GatewayConfiguration.md:195` documents all three seams and the "redaction applies to successful reads only" rule accurately.
|
||||
- **Tests exist for the corners:** `Tests/Gateway/Dashboard/DashboardSessionAclTests.cs`, `EventsHubTests.cs`, `SessionDetailsPageEventAclTests.cs`, `EventsHubViewerRegistryTests.cs`, `HubTokenServiceTests.cs`, `AlarmsHubPublisherTests.cs`, `DashboardGroupTagMappingTests.cs`; live-LDAP ACL proof via `IntegrationTests/DashboardLdapLiveTests.cs:186-243` (real bind → real group claims → GroupToTag → ACL admit/deny, plus Admin bypass).
|
||||
- **Docs honesty:** the short-name `GroupToRole`/`GroupToTag` key constraint (full-DN keys can never match the pre-stripped groups) is stated at the mapping site (`DashboardGroupTagMapping.cs:8-13,41-47`) and in the class summaries (`db7b1db`, `4b6f907`).
|
||||
|
||||
## New findings
|
||||
|
||||
### SEC-37 · Medium (Security / deployment posture) — The shared GLAuth offers no TLS, so the Production plaintext-LDAP hard-stop is unsatisfiable for every host that authenticates against it (promotion of NEXT-08)
|
||||
|
||||
**Files:** `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:208-214`; `docs/GatewayConfiguration.md:264-265`; `glauth.md:30,357-360`; `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md` (NEXT-08).
|
||||
|
||||
The SEC-06 guard refuses to start a `Production`-environment host with `Ldap:Transport=None`, and the config doc instructs "Deployed hosts must set `Ldaps` or `StartTls`" — but the only shared directory (`10.100.0.35:3893`) has LDAPS disabled, port 3894 closed, and answers StartTLS with `protocolError` (per the NEXT-07/08 record), and `glauth.md:30` documents "LDAPS | disabled in dev". Neither permitted value can work, so the instruction is unsatisfiable: every deployed gateway that binds this directory must either run plaintext (which `glauth.md:358-360` itself warns sends passwords cleartext) or avoid the `Production` label. That is exactly what happened — windev was relabelled `Staging` on 2026-08-07 to boot past the guard. The net posture is that the hard-stop protects no real deployment: hosts that would trip it are renamed around it, and the SEC-35 documentation note now formalizes the escape hatch. The dashboard admin surface (API-key CRUD, session Kill, secrets UI) sits behind credentials that transit this plaintext bind.
|
||||
|
||||
**Recommendation:** resolve the posture, in one of two written directions: (a) enable LDAPS (or StartTLS) on the shared GLAuth — certificate issuance plus a trust story on each gateway host, source of truth `scadaproj/infra/glauth/` — and flip the deployed hosts' `Transport`; or (b) record an explicit decision that production gateways bind a different, TLS-capable directory and that the shared GLAuth is dev/staging-only, and say so in `docs/GatewayConfiguration.md`'s `Transport` row and `glauth.md`. Until one lands, every "must use Ldaps/StartTls" sentence is aspirational and should be labeled as such. (Cross-repo: coordinate with the family per the umbrella-index rule.)
|
||||
|
||||
### SEC-38 · Low (Security) — The failure limiter's cross-peer aggregate layer can be sidestepped by pre-filling the per-peer key-id cap, restoring the `limit × addresses` guessing bound the layer was built to remove
|
||||
|
||||
**Files:** `src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ApiKeyFailureLimiter.cs:225-231` (aggregate fed only when `effectiveKeyId` is non-null), `:393-444` (`ResolvePartitionKey` returns the `(peer, null)` fallback once the address has minted `MaxKeyIdPartitionsPerPeer = 32` key ids), `:153-166` (`Check` consults the aggregate only via `effectiveKeyId`).
|
||||
|
||||
The SEC-32 cap collapses the 33rd-and-later key id from one address onto that address's fallback partition, and — per the comment's cardinality rationale — such failures deliberately do not feed the per-key-id aggregate. The side effect: an attacker who first mints 32 junk `mxgw`-shaped key-id partitions from an address (32 requests) makes every subsequent guess against the *victim's real key id* from that address resolve to `(peer, null)` — so those failures neither feed the victim's aggregate (`RecordFailure`, `:227`) nor consult it (`Check`, `:158-163`). A source-rotating attacker (the IPv6-/64 scenario the aggregate was designed for, per the SEC-31 remediation design's G1 analysis) thereby recovers `ApiKeyFailureLimit` guesses per window per address with the aggregate fully bypassed, at ~32 extra requests per address — instead of the designed bound of `ApiKeyFailureAggregateLimit` total followed by ~1 probe per interval globally. Severity is Low, not Medium: gateway secrets are library-generated high-entropy values, so online guessing remains computationally hopeless and the limiter is defense-in-depth plus store-read cost shielding (which still holds per-address); the finding is that the *documented* distributed-attack bound (`ApiKeyFailureLimiter.cs:17-19`, `docs/Authentication.md` limiter section) is not the actual bound.
|
||||
|
||||
**Recommendation:** feed (and consult) the aggregate for any *validly shaped* key id regardless of whether it earned a composite partition — the cardinality concern the comment cites is already handled by `_aggregates`' own capped eviction (`:234`), whose policy protects over-limit entries and preferentially evicts single-failure junk. Alternatively, count cap-overflow failures into the aggregate of the presented key id only when that aggregate already exists (zero new cardinality, closes the consult-bypass for hot keys). Update the class remarks and `docs/Authentication.md` either way.
|
||||
|
||||
### SEC-39 · Info (Security) — `Reset` treats "no key id" as owning the shared fallback partition: a verified token that fails the interceptor's key-id parse clears the address's fallback state the guard comment promises to preserve
|
||||
|
||||
**Files:** `src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ApiKeyFailureLimiter.cs:252-276`; `src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs:149-193`.
|
||||
|
||||
`Reset`'s ownership check is `string.Equals(effectiveKeyId, partition.KeyId)` (`:264`), which is true when both are null — and a partition with `KeyId = null` resolves to the transport-peer fallback key (`:398-403`), so the reset then `RemovePartition(peer)`s the *shared* fallback bucket, the exact wipe the comment above it (`:259-263`) explains must not happen because that bucket holds junk-spray failures from the same address. A null `KeyId` on a *successful* verification requires a token the library verifier accepts but `TryResolveKeyId` rejects — the identifiable shapes are a key id longer than 64 chars (`:178`) and a secret whose first character is `_` (`:187`, matching the old `Split`-based rejection); whether the shared verifier accepts either depends on library parsing not readable in this repo. Practical impact is minimal (the attacker must hold a *valid* key of an unusual shape merely to clear their own address's junk-spray throttle), hence Info — but the null==null branch contradicts the stated invariant and costs one line to close.
|
||||
|
||||
**Recommendation:** make the fallback non-resettable explicitly: `if (effectiveKeyId is not null && string.Equals(effectiveKeyId, partition.KeyId, StringComparison.Ordinal))`, and pin with a test (successful reset with a null-key-id partition leaves the fallback partition tracked).
|
||||
|
||||
### SEC-40 · Low (Security) — Alarm visibility is not tag-scoped: the session-less alarms hub (and alarms page) broadcasts galaxy-wide alarm metadata to every Viewer, including empty-grant Viewers and anonymous localhost
|
||||
|
||||
**Files:** `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs:12-25` (`[Authorize(HubClientsPolicy)]`, auto-join `__alarms__` on connect); `AlarmsHubPublisher.cs:48-63` (unfiltered `StreamAsync(alarmFilterPrefix: null)` re-broadcast to all clients); `Dashboard/DashboardAuthorizationHandler.cs:39-53` (loopback/disabled bypasses satisfy the Viewer requirement the hub policy uses); `docs/GatewayDashboardDesign.md:277` ("all clients receive every message").
|
||||
|
||||
The SEC-25 ACL scopes *session event* visibility by tag intersection, and CLAUDE.md/docs describe dashboard event visibility as tag-scoped — but the alarm feed is a parallel, session-less surface with no per-caller scoping at all: any principal satisfying `HubClientsPolicy` (any Viewer role, the `Authentication:Mode=Disabled` bypass, or anonymous localhost under the default `AllowAnonymousLocalhost=true`) receives every `AlarmFeedMessage` the central monitor emits. Values are stripped by default (`ShowTagValues`, verified above), but the surviving metadata — alarm reference (a full tag path), state, severity, category, area, and acknowledging-operator usernames — discloses process structure and operator activity for the whole subscription expression to callers the tag model would deny every single session. A Viewer with an empty tag grant, whom the ACL was explicitly designed to show nothing, still sees the full plant alarm picture. The alarm monitor being gateway-central (not per-session) means tag scoping does not map mechanically, so this may well be a deliberate posture — but no design doc states it against the ACL: `GatewayDashboardDesign.md:277` describes the fan-out without a scoping rationale, and the ACL plan (`docs/plans/2026-07-10-dashboard-session-acl-tst15.md`) does not carve alarms out.
|
||||
|
||||
**Recommendation:** decide and document. If deliberate, add one paragraph to the ACL section of `docs/GatewayDashboardDesign.md` (and the `GroupToTag` row of `docs/GatewayConfiguration.md`) stating that alarm metadata is intentionally visible to every dashboard Viewer and why (central monitor, no per-session provenance). If not, the cheap tightening is a dedicated policy on `AlarmsHub` (e.g. require a real role claim, excluding the anonymous-loopback bypass) or an area/tag-prefix filter derived from the caller's grant.
|
||||
|
||||
### SEC-41 · Low (Security) — Tag grants revoked by a `GroupToTag` config change survive on cookie principals across the restart that applies the change: the ACL trusts login-stamped tag claims for up to the 8-hour sliding cookie life, while the hub-token path re-resolves at mint
|
||||
|
||||
**Files:** `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs:145-149` (tags stamped as claims at login); `Dashboard/DashboardSessionAcl.cs:75-81` (ACL reads the *stamped* tag claims); `Dashboard/HubTokenService.cs:84-90` (token mint *re-resolves* tags from group claims against current options — the asymmetry); `Dashboard/DashboardServiceCollectionExtensions.cs:114-130` (`ZbCookieDefaults.Apply(..., idleTimeout: 8h)` — sliding, so an active session renews indefinitely).
|
||||
|
||||
`GatewayOptions` binds once at startup (no reload — noted at `DashboardSnapshotService.cs:45-49`), so changing `GroupToTag` requires a gateway restart. A restart tears down circuits and hub connections, but **not** the auth cookie: data-protection keys persist, and a browser returning after the restart re-authenticates from the cookie, whose `DashboardTagClaimType` claims were resolved under the *old* map at login. `DashboardSessionAcl` consults exactly those claims, so a user whose group's tag grant was removed from config keeps observing the corresponding sessions' event streams — hub joins and the session-details in-process seam alike — until they log out or the sliding 8-hour window finally lapses, renewable indefinitely by activity. The hub-token seam does not have this gap for the *grant* (mint re-resolves from group claims, bounding staleness to 5 minutes, exactly as its comment at `:41-47` claims) — but that comment's promise that "revoking a GroupToTag entry takes effect ... within one lifetime" holds only for token-authenticated connections, and the cookie-authenticated in-process seam it shares an ACL with is the weaker path. LDAP group-membership revocations have the same 8-hour cookie bound on both paths (standard cookie-auth behavior, but worth stating for a *security grant*).
|
||||
|
||||
**Recommendation:** have the ACL (or a claims transformation) re-resolve tags from the principal's `LdapGroupClaimType` claims against current options — the same one-call `DashboardGroupTagMapping.MapGroupsToTags` the token mint already uses — instead of trusting login-stamped tag claims; the group claims are already on the cookie. That makes a config-revoked grant take effect at the next subscribe after restart, symmetrically with the token path. At minimum, document the revocation bounds (restart + re-login for cookies, 5 minutes for tokens, never for an already-open subscription) in `docs/GatewayDashboardDesign.md`'s ACL section.
|
||||
|
||||
## Severity roll-up (new findings)
|
||||
|
||||
| Severity | Count | IDs |
|
||||
|----------|-------|-----|
|
||||
| Medium | 1 | SEC-37 |
|
||||
| Low | 3 | SEC-38, SEC-40, SEC-41 |
|
||||
| Info | 1 | SEC-39 |
|
||||
|
||||
Prior-backlog movement this period: SEC-19, SEC-21, SEC-26 resolved; SEC-31…SEC-36 all verified Done (SEC-36 including the operator rotation); still open from cycle 1: SEC-13 (impact reduced — redactor now wired fail-closed), SEC-14, SEC-15 (accepted), SEC-16, SEC-17, SEC-18, SEC-23, SEC-24 (narrowed), SEC-27.
|
||||
|
||||
## Still good / preserved
|
||||
|
||||
The load-bearing controls all survive at HEAD, several strengthened:
|
||||
|
||||
- **Fail-closed authorization core:** unrecognized request → `admin` scope (`GatewayGrpcScopeResolver`); opaque auth failures that never disclose the failing stage (`GatewayGrpcAuthorizationInterceptor.cs:96-109`); constant-time compare stays inside the shared verifier on every cache miss with only SHA-256 digests in memory (`CachingApiKeyVerifier.cs:36-39,284-286`); the throttle message reveals nothing about secret validity.
|
||||
- **The SEC-25 ACL is genuinely fail-closed on every branch** (unknown session, empty grant, untagged-session default `AdminOnly`, null principal), enforced identically on both subscribe seams, with the decision-table corners and the live-LDAP group-shape proof pinned by tests; session tags are immutable and inherited only from the owning key's constraints, never the client request; the gRPC event-stream attach stays owner-scoped (`EventStreamService.cs:52-63`).
|
||||
- **Read-only environment bypasses stay read-only:** the `Disabled`-mode and anonymous-loopback bypasses satisfy only requirements containing Viewer, never `AdminOnly` (`DashboardAuthorizationHandler.cs:37-53`); API-key CRUD and session Close/Kill keep their service-layer Admin re-checks (`DashboardApiKeyAuthorization.cs:10-18`, `DashboardSessionAdminService`) beneath the policy layer; the secrets UI mounts behind its own `secrets:manage` policy (`GatewayApplication.cs:147-150`), which the dashboard bypass handler cannot satisfy (different requirement type).
|
||||
- **Value redaction is now a complete, tested, three-seam system** (events mirror, alarms hub, `/browse`), clone-only so gRPC clients and the replay ring are untouched, with the config doc telling the truth about all three seams and the failed-read placeholder rule.
|
||||
- **Secret handling improved materially:** the committed LDAP literal is gone and rotated; config secrets ride fail-fast `${secret:}` references expanded pre-host; the effective-config projection redacts pepper name and LDAP password; hub tokens remain 5-minute, data-protected, identity-required; no new call site logging credentials, tag values, or grant contents was found in the changed files (the ACL logs decisions, never tag values — `DashboardAuthenticator.cs:145-146`).
|
||||
- **Credential-store paths are now defended in depth:** host-meaningful rooting, the under-content-root rule born of a real outage, and absolute defaults for all three stores (auth DB, secrets store, Galaxy snapshot) via one shared rule set (`GatewayConfigPathRules`).
|
||||
- **Dashboard hygiene held:** local Bootstrap only (no UI component libraries in any new page); antiforgery on POST login/logout; `SanitizeReturnUrl` still blocks open redirects (`DashboardEndpointRouteBuilderExtensions.cs:238-249`); the login limiter stays remote-IP-partitioned; the viewer registry / snapshot feed / broadcaster concurrency reworks (`EventsHubViewerRegistry.cs`, `DashboardSnapshotFeed.cs`, `DashboardEventBroadcaster.cs`) are careful, generation-guarded code with the subtle orderings documented in place and covered by dedicated tests.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Language Clients — Architecture Review (Cycle 3)
|
||||
|
||||
**Date:** 2026-08-19 · **Commit:** `8b3e3b3` (main) · **Baseline:** `4f5371f` (2026-07-12 cycle) · **Method:** static review on macOS (no builds, no tests run)
|
||||
|
||||
Scope: `clients/dotnet` (lib + CLI + tests), `clients/go` (mxgw-go), `clients/java`, `clients/python`, `clients/rust` (lib + CLI + build.rs + vendored protos), cross-client parity, `docs/CrossLanguageSmokeMatrix.md`, `docs/ClientLibrariesDesign.md`, `docs/ClientPackaging.md`, `docs/ClientBehaviorFixtures.md`, client READMEs, `scripts/pack-clients.ps1` / `scripts/tag-go-module.ps1`. Prior findings tracked as CLI-01..34 (`archreview/remediation/00-tracking.md`) and CLI-35..45 (`archreview/2026-07-12/remediation/50-clients.md` + `00-tracking.md`), plus NEXT-02 (`archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md`).
|
||||
|
||||
Since 4f5371f the client family absorbed: the full CLI-35..45 remediation train, NEXT-02, the 0.2.0/0.2.1 family publish (2026-08-07, `5b153da`), TST-24 real-wire tests for .NET and Python (`a8f86b5`), alarm truncation-flag regen (`d9ea8a8`), the feed-level `snapshot_status` rollout (`c748361` + `4d4f443`), StreamAlarms open-sequence doc truth (`c3c603f`), and Gradle-9 survival for `checkGeneratedClean` (`df45cb4`).
|
||||
|
||||
## Prior-finding verification
|
||||
|
||||
### 2026-07-12 cycle (CLI-35..45) and NEXT-02
|
||||
|
||||
| ID | Claimed status | Verified? | Evidence (file:line at HEAD) | Notes |
|
||||
|----|----------------|-----------|------------------------------|-------|
|
||||
| CLI-35 | Done | **Yes** | `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:24` (imports `ReplayGap`), `:1660-1666` (`_event_row` branches on `isinstance(item, ReplayGap)` and emits `{"replayGap": {"requestedAfterSequence": …, "oldestAvailableSequence": …}}`); regression `clients/python/tests/test_cli.py:935` (`test_stream_events_renders_replay_gap`, asserts exit 0, the gap row at index 0, the normal event row after, `:971-974`) | Exactly the designed fix, camelCase row matching the cross-CLI shape. |
|
||||
| CLI-36 | Done | **Yes** | `clients/go/cmd/mxgw-go/main.go:1012-1029` (`if result.IsReplayGap()` → JSON `replayGapCursors` struct `:70`, text `REPLAY_GAP requested_after=%d oldest_available=%d`); test `clients/go/cmd/mxgw-go/main_test.go:734` (`TestRunStreamEventsPrintsReplayGap` over a real fake gateway `:698-713`, both output modes) | Gap counted toward `-limit` as designed. |
|
||||
| NEXT-02 | Resolved 2026-08-10 | **Yes** | .NET `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs:1425-1429` (stream loop branch), `:1845-1846` (`EventToJsonElement` inside `--json` aggregate), `:1858-1873` (`FormatReplayGapRow` — hand-built so cursors are JSON numbers, not proto-JSON uint64 strings); Java `clients/java/zb-mom-ww-mxgateway-cli/.../MxGatewayCli.java:1665-1674` (`replayGap` JSON row + `REPLAY_GAP` text line); tests `MxGatewayCliTests.java:909-937`, `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs` (grep `FormatReplayGapRow`/`replayGap` hits); `docs/CrossLanguageSmokeMatrix.md:43-67` now records one converged contract with a per-CLI table | All five CLIs emit the same two camelCase keys with numeric cursors; the matrix doc explains *why* the row is hand-typed (proto3 JSON stringifies uint64). Genuinely converged. |
|
||||
| CLI-37 | Done | **Yes — all five conformant on `category`** | .NET `MxStatusProxyExtensions.cs:15-20` (`Category is MxStatusCategory.Ok` only); Go `clients/go/mxgateway/status.go:15-17` (`nil ∥ category == OK`, doc `:7-14` states the diagnostics-only rule); Java `MxStatuses.java:33-35` (+ `success()` Javadoc `:55-57` marks it diagnostic); Python `errors.py:144-152`; Rust `error.rs:407-413` (`status.category != Ok as i32`, doc `:383-388` covers the unspecified-category-fails edge); shared fixtures `clients/proto/fixtures/behavior/command-replies/write.status-category-error-success-set.reply.json` + `write.status-category-ok-success-zero.reply.json`, registered in the manifest, documented in `docs/ClientBehaviorFixtures.md` (Reply Validation Conformance table), and consumed by all five suites (`MxCommandReplyExtensionsTests.cs`, `protofixtures_test.go`, `MxGatewayFixtureTests.java`, `test_errors.py`, `client_behavior.rs`) | The 4-vs-1 divergence is gone; the edge decisions (absent entry = success, present-unspecified = failure) are uniform and documented. |
|
||||
| CLI-38 | Done | **Yes — all five on `hresult < 0`** | .NET `MxCommandReplyExtensions.cs:38` (`HasHresult && Hresult < 0`); Go `errors.go:299` (`GetHresult() < 0`); Java `MxGatewayErrors.java:52` (`getHresult() < 0`); Python `errors.py:137`; Rust `error.rs:407`; fixtures `write.hresult-s-false.reply.json` (S_FALSE=1 → success) + `write.hresult-e-fail.reply.json` (E_FAIL → failure) wired into all five suites | Lands prior-cycle CLI-08 (cycle-1 tracker row updated to Done via CLI-38 — verified). `docs/ClientLibrariesDesign.md:112-125` is now *true*, and states both rules explicitly. |
|
||||
| CLI-39 | Done | **Yes (bump + both guards); pattern has already recurred → CLI-46** | Versions: `clients/rust/Cargo.toml:3`/`:28` = 0.2.0, `clients/python/pyproject.toml:9` + `version.py:3` = 0.2.0 (+ new drift test `test_cli.py:17` `test_version_matches_pyproject_toml`, closing the CLI-26 residual), `clients/go/mxgateway/version.go` = 0.2.0, `.../ZB.MOM.WW.MxGateway.Client.csproj:22` = 0.2.0, Contracts `.csproj:10` = 0.2.0 (`440e7cf`), Java `build.gradle` = 0.2.1. Guards: `scripts/tag-go-module.ps1:40-53` (tag fails on `ClientVersion` mismatch — the CLI-21 guard finally implemented), `scripts/pack-clients.ps1:91-156` (`Test-GiteaPackageExists` + hard abort "never force-overwrites"). Publish record: `docs/ClientPackaging.md:102-113` (2026-08-07: nuget/pypi/cargo 0.2.0, maven 0.2.1 with the rationale for the Java exception, Go tag `clients/go/v0.2.0` — tag exists in this repo) | Executed to the letter, including the doc's "bump before every publish" rule and the collision-guard rationale. However the *same hazard is live again at HEAD*: post-publish library changes (incl. a shipped Python `connect()` fix) sit at the already-published 0.2.0/0.2.1 — see **CLI-46**. |
|
||||
| CLI-40 | Done | **Yes** | Java `MxGatewaySecrets.java:62-78` (`redactExact`); .NET `MxGatewaySecretRedaction.cs` (`Redact`/`RedactReply:55-68`/`RedactStatus:81-89`/`RedactStatuses` — scrubs message *and* the structured reply accessors, the `0d874f9` follow-up); Rust `error.rs:208-231` (`MxAccessError.secrets` + `with_secrets`, hand-written `Debug` prints only the count `:202-204`); fixtures `authenticate-user.echoed-credential.reply.json` + `.echoed-credential-mxaccess-failure.reply.json` in the manifest, consumed by all five suites (`MxGatewaySessionReplyContractTests.cs`, `command_reply_fixtures_test.go`, `MxGatewayCredentialReplyTests.java`, `test_malformed_reply.py`/`test_errors.py`, `client_behavior.rs`) | The echoed-credential fixture is exactly the test the prior review said was missing ("the fakes don't echo"). |
|
||||
| CLI-41 | Done | **Yes** | Go `errors.go:164-176` (`MalformedReplyError`); Python `errors.py:56`; Java `MxGatewayMalformedReplyException.java` + throw sites `MxGatewaySession.java:889/:917/:948` (redaction rethrow preserves the type `:1143-1144`); .NET `MxGatewayMalformedReplyException.cs`; Rust `session.rs:1024-1042` (payload → present `return_value` → `Error::MalformedReply`, now uniform across its extractors); fixtures `authenticate-user.missing-payload.reply.json` + `authenticate-user.return-value-only.reply.json`; contract documented in `docs/ClientLibrariesDesign.md` ("Malformed-reply extraction" paragraph) | Silent-0 user ids and the .NET NRE are gone; the payload → return_value → typed-error ladder is uniform. |
|
||||
| CLI-42 | Done | **Yes** | `clients/rust/README.md:24-36` (repo-path-first / vendored-fallback resolution, same-commit refresh rule, both-direction drift guard); `docs/ClientPackaging.md:199-213` (same, plus why `cargo package` runs *with* verification); `711793f` extended check-codegen Check 3 to sweep both directions (canonical proto missing a vendored copy now fails) | Vendored copies verified byte-identical to Contracts at HEAD (all three protos, diff clean). |
|
||||
| CLI-43 | Done | **Yes** | `docs/style-guides/JavaStyleGuide.md:8` — "Target Java 17 (the Ignition 8.3 baseline; the client build enforces …)" | `grep "Java 21"` in style-guides: clean. |
|
||||
| CLI-44 | Done | **Yes** | `clients/go/mxgateway/session.go:1077` (Recv-error path uses `sendTerminalEventResult`), `:1096-1111` (bypasses the overflow substitution via the reserved slot) | Root cause of a genuine stream error is no longer relabeled `ErrSlowConsumer`. |
|
||||
| CLI-45 | Done | **Yes** | Canonical env `MXGATEWAY_VERIFY_PASSWORD` in all five: Go `main.go:452` (default) + `:483-491` (fail-fast before dialing, message names flag+var only); Java `MxGatewayCli.java:194` + `:1166-1179` (`ParameterException` on null/blank); Rust `main.rs:151` (clap default) + test `:2644-2660`; Python `commands.py:38` (`DEFAULT_VERIFY_PASSWORD_ENV`); .NET `MxGatewayClientCli.cs:353-359` (canonical default + `MXGATEWAY_VERIFY_USER_PASSWORD`/`--verify-user-password*` kept as deprecated aliases, resolution order documented `:385-386` and in help `:2455-2456`). Tests: Go `main_test.go:605` (`RejectsEmptyPassword`) + `:632` (`ReadsPasswordFromCanonicalEnv`). Contract documented at `docs/CrossLanguageSmokeMatrix.md:90-119`, incl. the parity rationale (libraries still transmit whatever they are given) and the .NET deprecation window | One exported variable now drives all five CLIs; empty credential is a usage error everywhere. |
|
||||
|
||||
**Done claims that don't fully hold:** none. Every 2026-07-12 remediation claim checked out at HEAD with the designed shape (fixtures-first conformance, docs in the same commit, guards actually implemented — including the CLI-21 tag guard skipped last cycle). This is the cleanest verification pass of the three cycles.
|
||||
|
||||
### Cycle-1 residue (CLI-05..33, previously Open)
|
||||
|
||||
Re-verified briefly at HEAD; all unchanged unless noted:
|
||||
|
||||
- **CLI-08 → Done** via CLI-38 (see above); cycle-1 tracker row correctly updated.
|
||||
- Still open, unchanged: **CLI-05** (`MxGatewaySession.cs:19` internal ctor, no .NET `AttachSession`), **CLI-06** (`:1501-1505` `DisposeAsync` → unbounded `CloseAsync`), **CLI-07** (`MxGatewayClient.cs:311`), **CLI-09** (no typed auth errors in Go `errors.go`), **CLI-10** (`client.go:64/:68` `WithBlock`/`DialContext`), **CLI-11** (library has `RequireCertificateValidation` (`client.go:239`) but `mxgw-go` still exposes no flag), **CLI-13** (`MxGatewayClient.java:248` hardcoded 16), **CLI-14** (no TOFU runtime warning in `options.py`), **CLI-17/20** (TLS posture divergence: .NET accept-all `MxGatewayClient.cs:369`, Go skip-verify, Java `InsecureTrustManagerFactory` `MxGatewayClient.java:391`, Python TOFU, Rust strict), **CLI-19** (duplicate `InternalsVisibleTo`), **CLI-22** (`session.go:1176-1181` empty correlation id on rand failure), **CLI-23** (nil-vs-empty asymmetry — now at least documented in the `WriteBulk` doc comment `session.go:431-432`), **CLI-25** (`close()` doesn't await termination `MxGatewayClient.java:347-351`), **CLI-27** (`session.py` close still lock-free), **CLI-28** (bottom-of-file import `session.py`), **CLI-31** (`mxgw-cli/src/main.rs` now **2,991** lines — grew again; the other CLIs sit at 1,668–2,478), **CLI-32** (no .NET/Java client-side bulk cap), **CLI-33** (no backpressure section in `docs/ClientBehaviorFixtures.md`).
|
||||
|
||||
## New findings
|
||||
|
||||
### CLI-46 — Version constants again equal the already-published 0.2.0/0.2.1 while post-publish changes shipped — including the fix for a published-broken Python `connect()`
|
||||
**Severity:** Medium · **Category:** packaging / release hygiene
|
||||
**Files:** `clients/python/pyproject.toml:9` + `clients/python/src/zb_mom_ww_mxgateway/version.py:3` (0.2.0 = the pypi artifact published 2026-08-07, `docs/ClientPackaging.md:102-113`); `clients/python/src/zb_mom_ww_mxgateway/client.py:61-72` (the fixed `connect()`); post-publish library commits `a8f86b5` (2026-08-10), `d9ea8a8`, `c748361` (2026-08-17/18); guard `scripts/pack-clients.ps1:145-156`
|
||||
The published pypi 0.2.0 Python client cannot open a real connection at all: its `connect()` built the `grpc.aio` channel inside `asyncio.to_thread`, and a `grpc.aio` channel binds to the event loop current on the constructing thread, so every non-stub connection raised "There is no current event loop in thread" (TST-24's first real-wire run caught it; fixed in-tree by `a8f86b5` — `resolve_channel_security` runs off-loop, `create_channel` on-loop). That fix has sat unpublished for nine days while the version constant still reads 0.2.0. Since the publish, all five libraries also gained wire-surface additions at the published numbers (alarm truncation fields `d9ea8a8`, feed-level `snapshot_status` bindings `c748361`; Java at its published 0.2.1). This is precisely the CLI-39 pattern one cycle later — now *mitigated* (the pack-script collision guard refuses a same-version publish, and the bump-together deferral is deliberate) but the live registry keeps serving a broken Python client, and nothing in the tree records that 0.2.0-as-published ≠ 0.2.0-as-source.
|
||||
**Recommendation:** cut the next release train soon (0.2.1 four clients / 0.2.2 Java, or 0.3.0 across the board), prioritizing the Python publish; until then, note the known-broken `connect()` against pypi 0.2.0 in `clients/python/README.md`/`docs/ClientPackaging.md` (or yank the pypi artifact). Consider bumping version constants in the same commit as the first post-publish library change rather than at publish time — the collision guard then enforces nothing because nothing needs catching.
|
||||
|
||||
### CLI-47 — `snapshot-status`/`provider-status` rendering diverges: .NET text mode hides the flag on the frames that matter most; Rust's JSON is the lone always-explicit shape
|
||||
**Severity:** Low · **Category:** cross-client parity / CLI
|
||||
**Files:** .NET `MxGatewayClientCli.cs:15` (`JsonFormatter.Default` — omits default-valued proto3 fields) + `:1563-1566` (text arms format the payload through it); Go `main.go:1112-1114` (`snapshot-status truncated=%t`); Java `MxGatewayCli.java:2309-2312` (`truncated=%b`) + `protoJson` `:2362-2368` (default printer); Rust `main.rs:2262-2264` (summary) + `:2314-2318` (hand-built JSON, always includes `"truncated"`); Python CLI renders generic `MessageToDict`
|
||||
The c748361 rollout achieved surface parity (all five render the new arm) but not rendering parity. The `snapshot_status` clearing frame — `truncated = false`, emitted on monitor restart, explicitly called out by the commit and `docs/Grpc.md` as a frame consumers must see — renders in the .NET CLI's text mode as `snapshot-status {}` (proto3 JSON omits the false bool), while Go/Java/Rust print `truncated=false` explicitly. The same applies to a healthy `provider-status` (`degraded=false` invisible in .NET text). In JSON mode the split flips: Rust's hand-built row always carries `"truncated"`, while .NET/Go/Java/Python protobuf formatters emit `{"snapshotStatus": {}}` for the clearing frame. Semantically recoverable (absent = false), but the same class of divergence NEXT-02 just cleaned up for `ReplayGap`, where the cross-CLI row was deliberately hand-specified.
|
||||
**Recommendation:** hand-format the .NET text arms like the other three (`snapshot-status truncated={bool}`, `provider-status mode=… degraded=… reason=…`); decide one JSON shape (either Rust adopts formatter-omission or the matrix documents Rust's explicitness the way `CrossLanguageSmokeMatrix.md:54-67` documents the ReplayGap row's deliberate choices).
|
||||
|
||||
### CLI-48 — The new alarm-feed renderer arms are untested in three of the four CLIs that changed; the Go CLI has no `stream-alarms` test at all
|
||||
**Severity:** Low · **Category:** testing coverage
|
||||
**Files:** `clients/go/cmd/mxgw-go/main_test.go` (test inventory `:16-734` — no stream-alarms/`formatAlarmFeedMessage` test of any kind); .NET `MxGatewayClientCliTests.cs:671-706` (`stream-alarms` test feeds only `ActiveAlarm` + `SnapshotComplete`); Java `MxGatewayCliTests.java:653-687` (asserts `active-alarm`/`snapshot-complete`/`transition` arms only); Rust `main.rs:2986` (the one snapshot-status render test, added by `4d4f443`)
|
||||
The review-nits commit covered only the Rust render; the .NET/Go/Java `provider-status` and `snapshot-status` arms shipped with zero assertions. The .NET `{}`-rendering divergence (CLI-47) is exactly what a text-mode assertion on a `truncated=false` frame would have caught — cycle 2's lesson ("each new epic achieves surface parity but re-introduces behavioral divergence") repeating at the test layer.
|
||||
**Recommendation:** one fake-fed `stream-alarms` test per CLI whose feed includes a `provider_status` and a `snapshot_status` frame (including the false/clearing shape), asserting the rendered row in both output modes; for Go this also creates the missing `stream-alarms` scaffold.
|
||||
|
||||
### CLI-49 — Go library exposes no aliases for `AlarmProviderStatus`/`AlarmSnapshotStatus`, whose generated types live in an `internal` package; the `AlarmFeedMessage` alias doc names three of five payload arms
|
||||
**Severity:** Low · **Category:** code organization & conventions / documentation
|
||||
**Files:** `clients/go/mxgateway/types.go:129-137` (alias block: `AlarmFeedMessage`, `ActiveAlarmSnapshot`, `OnAlarmTransitionEvent` — but not the two status payloads; `ReplayGap` got its alias at `:38` when it shipped); generated types under `clients/go/internal/generated` (Go `internal` visibility)
|
||||
Because the generated package is `internal`, a Go consumer cannot name `*generated.AlarmSnapshotStatus`: they can call `message.GetSnapshotStatus().GetTruncated()` on an inferred value but cannot declare a variable, struct field, or function parameter of the type — unlike every other StreamAlarms payload, which `types.go` deliberately aliases for exactly this reason. (`AlarmProviderStatus` has had the same gap since provider-mode shipped.) The `AlarmFeedMessage` alias comment (`types.go:130-132`) still describes the oneof as "an active-alarm snapshot row, a snapshot-complete sentinel, or a transition" — stale by two arms.
|
||||
**Recommendation:** add `AlarmProviderStatus = pb.AlarmProviderStatus` and `AlarmSnapshotStatus = pb.AlarmSnapshotStatus` aliases and refresh the `AlarmFeedMessage` comment to name all five arms.
|
||||
|
||||
### CLI-50 — Python CLI `stream-alarms` default (`--max-messages 1`, collect-then-print) now returns only the provider-status preamble — the README's own example fetches no alarm data
|
||||
**Severity:** Low · **Category:** cross-client parity / CLI usability
|
||||
**Files:** `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:514` (`--max-messages` default 1), `:1539-1551` (`_collect_alarm_messages` — bounded collect, printed once at the end), `:1122-1134`; `clients/python/README.md:451` (`mxgw-py stream-alarms --max-messages 1 --json`); feed order per `docs/Grpc.md`/`c3c603f`: `provider_status` → `snapshot_status` → cached `active_alarm` → `snapshot_complete`
|
||||
The other four CLIs stream rows as they arrive and default to unbounded (`--limit 0` / `--max-events 0`). Python is the outlier twice over: it buffers then prints (so it cannot tail a live feed at all), and its default of 1 — reasonable when the first frame was an alarm — now returns exactly one `provider_status` frame, since the feed gained a two-frame status preamble. The README example therefore demonstrates a command that yields no alarm rows. Pre-existing shape, but the preamble growth this cycle changed what the default actually returns.
|
||||
**Recommendation:** raise the default (or document that N status frames precede the data) and update the README example; longer term consider printing rows incrementally like the other four CLIs.
|
||||
|
||||
## Severity roll-up (new findings)
|
||||
|
||||
| Severity | Count | IDs |
|
||||
|---|---|---|
|
||||
| High | 0 | — |
|
||||
| Medium | 1 | CLI-46 |
|
||||
| Low | 4 | CLI-47, CLI-48, CLI-49, CLI-50 |
|
||||
|
||||
## Cross-client parity note (updated)
|
||||
|
||||
The 2026-07-12 divergence table has largely collapsed. Converged this cycle, with shared fixtures pinning the bytes: **HRESULT `< 0`** (CLI-38), **status verdict = `category != OK`** (CLI-37), **exact-secret scrub incl. echoed diagnostics** (CLI-40), **malformed-reply ladder** (CLI-41), **CLI ReplayGap row** (CLI-35/36 + NEXT-02, all five, same keys, numeric cursors), **credential env-var contract** (CLI-45). What remains divergent:
|
||||
|
||||
| Behavior | .NET | Go | Java | Python | Rust |
|
||||
|---|---|---|---|---|---|
|
||||
| Version vs published artifact (CLI-46) | 0.2.0 = shipped | 0.2.0 = shipped | 0.2.1 = shipped | 0.2.0 = shipped (**broken `connect()` on pypi**) | 0.2.0 = shipped |
|
||||
| CLI text render of snapshot/provider status (CLI-47) | proto-JSON, flag invisible when false | explicit `truncated=…` | explicit | n/a (JSON only) | explicit ✓ |
|
||||
| CLI JSON shape of `snapshot_status` (CLI-47) | omits false | omits false | omits false | omits false | always explicit |
|
||||
| New renderer arms tested (CLI-48) | no | no (no stream-alarms test) | no | n/a (generic dump) | yes ✓ |
|
||||
| `stream-alarms` consumption model (CLI-50) | stream, unbounded default | stream, unbounded | stream, unbounded | **collect, default 1** | stream, unbounded |
|
||||
| Session re-attach (CLI-05) | **No** | Yes | Yes | Yes | Yes |
|
||||
| Client-side bulk cap (CLI-32) | No | Yes | No | Yes | Yes |
|
||||
| Event backpressure (CLI-13/33) | unbuffered | 16+1, loud, true root cause ✓ | 16, cancel+error | unbuffered | unbuffered |
|
||||
| TLS default (CLI-17) | accept-all | accept-all | accept-all | TOFU pin | strict pin |
|
||||
| Typed auth errors (CLI-09) | Yes | **No** | Yes | Yes | Yes |
|
||||
| Real-wire test harness | Kestrel h2c ✓ (TST-24) | bufconn ✓ | InProcessServer ✓ | grpc.aio loopback ✓ (TST-24) | tonic loopback ✓ |
|
||||
|
||||
CLI subcommand coverage remains uneven by design and is now honestly specified (`docs/CrossLanguageSmokeMatrix.md:163-186`: .NET all nine, Rust + unregister/credential pair, Go/Python/Java credential pair only) — verified accurate against all five dispatch tables at HEAD, including the note that every gap is CLI-surface-only (all five libraries carry all typed helpers, confirmed for the Rust bulk family at `session.rs:605-654`).
|
||||
|
||||
## What is good
|
||||
|
||||
- **The remediation train executed at design fidelity.** All eleven 2026-07-12 findings landed exactly as their remediation designs specified — including the parts prior cycles skipped (the CLI-21 tag guard, the CLI-02/42 doc half, the CLI-26 pyproject-drift test). The conformance findings (CLI-37/38/40/41) all went fixtures-first: ten shared command-reply fixtures under `clients/proto/fixtures/behavior/command-replies/`, each consumed by all five suites, so the canonical rules are pinned in bytes, not prose.
|
||||
- **The publish pipeline is now guarded end to end**: version-vs-tag check in `tag-go-module.ps1`, registry-collision hard-stop in `pack-clients.ps1`, publish coordinates and the Java 0.2.1 exception recorded in `docs/ClientPackaging.md` with the reasoning ("these are the numbers a future release bumps off").
|
||||
- **TST-24 paid for itself immediately**: the first real-wire run of the Python client found `connect()` could never work outside stubs — a defect every mock-based test had actively asserted as correct. All five clients now have genuine-transport tests (framing, metadata, status codes, ReplayGap, PERMISSION_DENIED mapping), and CI runs all five suites plus a Java generated-tree drift check (`.gitea/workflows/ci.yml:84-142`).
|
||||
- **Generated-code hygiene is the best it has been**: vendored Rust protos byte-identical to Contracts (verified, all three files); check-codegen Check 3 sweeps both directions; the Java toolchain pin eliminated the spurious ~64k-line protobuf churn (per `build.gradle:61-70`, IPC-24) and `checkGeneratedClean` survived Gradle 9 (`df45cb4`); Python regen stays on the pinned grpcio-tools 1.80.0 / protobuf 6.31.1 baseline (`pyproject.toml:41-44`, generated header confirms 6.31.1); even a proto-comment-only change (`c3c603f`) regenerated every downstream tree in the same commit.
|
||||
- **Parity discipline held through the alarm epic**: no client wraps or reinterprets `AlarmFeedMessage`/`ActiveAlarmSnapshot` — the truncation and status flags pass through the generated types untouched in all five, and every README explains the `from_truncated_snapshot`/`snapshot_status` semantics with the same don't-reconcile-deletions guidance, explicitly disambiguating them from the per-record `degraded` flag.
|
||||
- **The smoke-matrix doc is a model of honest divergence-recording**: where the CLIs still differ (subcommand coverage, JSON formatting quirks per language), the doc specifies the delta instead of leaving it to be discovered — the exact practice this review series asked for two cycles ago.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Testing, Documentation & Underdeveloped Areas — Re-Review (cycle 3)
|
||||
|
||||
- **Date:** 2026-08-19
|
||||
- **Commit:** `8b3e3b3` (`main`, "Merge feat/sweep-reread-tag-summary…")
|
||||
- **Baseline:** prior report `archreview/2026-07-12/60-testing-docs-gaps.md` @ `4f5371f` (2026-07-12); ~288 commits since.
|
||||
- **Method:** macOS static review — reading only, no builds, no test runs, no remote hosts. One read-only script executed (`python3 code-reviews/regen-readme.py --check`, sanctioned by REVIEW-PROCESS.md). All paths relative to `/Users/dohertj2/Desktop/MxAccessGateway`.
|
||||
|
||||
## Prior-finding verification
|
||||
|
||||
### Cycle-2 findings (TST-25..30)
|
||||
|
||||
| ID | Claimed status | Verified? | Evidence (file:line) | Notes |
|
||||
|----|----------------|-----------|----------------------|-------|
|
||||
| TST-25 | Done | **Yes** | `.gitea/workflows/ci.yml:144-165` (`windows-x86` per-push job, Linux runner → SSH to windev, exit-code propagation) and `:167-201` (`nightly-windev`, `if: github.event_name == 'schedule'`, `run-windev-ci.sh live`, on-failure Gitea-issue step); `scripts/ci/run-windev-ci.sh` (SHA-shape validation `:46-49`, base64 key decode `:70-78`, bootstrap holds the worktree lock `:91-119`); `scripts/ci/windev-worker-ci.ps1` (lock reuse via `MXGW_CI_LOCK_HELD` `:77-94`, re-checkout under lock + HEAD verification `:99-111`, cumulative build/test/live modes `:119-139`) | The whole design shipped as specified, including the acceptance-check hardening: the CI key is stored base64 single-line so Gitea's line-oriented masker redacts it (`ci.yml:156-159`), the bootstrap fetch/checkout races were serialized under the mkdir lock, and the nightly failure issue carries a browser-reachable link via `PUBLIC_SERVER_URL` (`ci.yml:174-181`, Check 6 closed 2026-08-10 per `docs/runbooks/TST-30-second-ci-runner.md`). Ground truth from the operator confirms the tier runs green. |
|
||||
| TST-26 | Done | **Yes** | `docs/GatewayTesting.md:624-657` describes exactly the four jobs that exist (`portable`, `java`, `windows-x86`, `nightly-windev`) incl. the "red can mean the tier is down" triage (`:649-652`); `scripts/check-codegen.ps1:26-27` now attributes the x86 guard to "the SSH-driven `windows-x86` CI job"; `ci.yml:14-18` cron comment matches what the schedule runs | Guard attribution is correct both ways: primary = Check 2 regeneration diff in `portable`, secondary = the windows-x86 net48 compile (`GatewayTesting.md:699-703`). No named job is missing and no phantom job survives. |
|
||||
| TST-27 | Done | **Yes** | `docs/GatewayConfiguration.md:195` — the `ShowTagValues` row now describes all three seams (events-hub mirror, alarms hub, `/browse` live values incl. the successful-read-only nuance from `e50ec49`), the clone-don't-mutate guarantee, and the ACL layering | "Reserved" is gone; the row was rewritten a second time when TST-16's residuals shipped (2026-08-17), so it documents strictly more than the finding asked. |
|
||||
| TST-28 | Done | **Yes** | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs:46` — `StartAsync_SendsGatewayHelloWithConfiguredMaxFrameBytes(int maxMessageBytes)` theory, in the CI-run portable suite | Exactly the designed test (default + override), pinning the gateway half of the IPC-02 negotiation on Linux CI. |
|
||||
| TST-29 | Done | **Yes** | `oldtasks.md` absent from the tree; the five `*-docs-*.md` root artifacts absent; `docs/DesignDecisions.md:264` "Session-Resilience Epic Scope" carries the Phase-5 deferred-not-planned record and `:295` the "`EnableOrphanReattach` does not exist and must not be referenced" rule; CLAUDE.md's invariant paragraph points at DesignDecisions | Governance record migrated intact before deletion, exactly per the design. Residual: `stillpending.md` (also a TST-14 root artifact) still contradicts the migrated record — see new finding TST-33. |
|
||||
| TST-30 | Done | **Yes, with one pending operator action** | `docs/runbooks/TST-30-second-ci-runner.md:1-13` (Executed 2026-08-07: `gitea-runner-2`, id 5, capacity 2, `container.network: traefik`, token from a `0600` file mount; concurrency verified across three simultaneous runs) plus the honest correction that the per-repo runners endpoint legitimately returns 0 (instance-level registration); `docs/GatewayTesting.md:659-685` documents the shared-capacity/no-cancel reality and the `run-windev-ci.sh` bypass | The runner exists and the docs match the executed state. The **follow-up it surfaced is still open**: the shared instance registration token (world-readable ~5 months, proven still live by a probe registration) needs a web-UI reset — promoted to new finding TST-32 so it stops living only inside a closed finding's runbook. |
|
||||
|
||||
**Done claims that failed verification: none.** All six hold with file:line evidence; TST-30 carries the one explicitly-tracked operator residual (TST-32 below).
|
||||
|
||||
### Cycle-1 leftovers still tracked open (spot-verified at HEAD)
|
||||
|
||||
| ID | Tracker status | State at HEAD |
|
||||
|----|----------------|---------------|
|
||||
| TST-05 | Partially done | **Residual confirmed open.** The live suite is 8 `[LiveMxAccessFact]`s (`WorkerLiveMxAccessSmokeTests.cs:34-920`) with zero references to `Ping`/`GetSessionState`/`GetWorkerInfo`/`DrainEvents`/`ShutdownWorker`; the gap is honestly documented at `docs/GatewayTesting.md:90-95`. The scheduling half stays closed (nightly-windev runs the suite). The two control-command live facts specified in `archreview/remediation/60-testing-docs-gaps.md` remain unauthored (windev-only work). |
|
||||
| TST-06 | Not started | **Materially narrowed, tracker stale.** `Tests/Gateway/Dashboard/DashboardLiveDataServiceTests.cs`, `EventsHubTests.cs`, `EventsHubViewerRegistryTests.cs`, `AlarmsHubPublisherTests.cs`, `SessionDetailsPageEventAclTests.cs` all exist now (mostly landed with SEC-25/TST-16 closeouts and the in-process dashboard-feed work). The cycle-1 row deserves a re-audit-and-close pass rather than "Not started". |
|
||||
| TST-07 | Not started | Partially addressed: the `WorkerClientTests` 150 ms sleep-then-negative-assert is gone; `SessionManagerTests.cs:491` still has a real-clock `Task.Delay(50)`. |
|
||||
| TST-09 | Not started | Partially addressed: `GatewayApplication.cs:117-124` now registers `AuthStoreHealthCheck` **and** `SessionHealthCheck` (the `/health/active` probe with off-repo consumers). Still no Galaxy SQL / LDAP / worker-executable checks. |
|
||||
| TST-10 | Not started | Still no `docs/Deployment.md`; partial mitigation via `docs/runbooks/IdentifyingADeployedBuild.md` and the SEC-36 rotation runbook, but deploy/upgrade mechanics (dated `Server-YYYYMMDD` dirs, NSSM repoint, env-var config) still live in operator memory notes, and the 2026-08-09 credential-store-renamed-away outage (`docs/GatewayConfiguration.md:100`) shows the cost. |
|
||||
| TST-14 | Not started | Mostly discharged by TST-29 (oldtasks + the five untracked artifacts gone). Remaining root-file triage: `stillpending.md` (now the stale one — see TST-33) and `A2-galaxyrepository-adoption-handoff.md` (carries a current "ADOPTED" banner; acceptable as a handoff record). |
|
||||
| TST-17 | Not started | **Materially changed — row needs refresh by the worker domain.** `WnWrapAlarmConsumer.cs:309-327` now passes `szDomainName`/`szOprFullName` into the COM call (the `_ = ackOperatorDomain` discard is gone), and the 2026-08 probe cycle recorded the vendor 8-arg leg as "unavailable by configuration" with evidence (`docs/AlarmProbeFindings.md`, commits `d1ae43d`, `540a118`). Whether a caller-visible degrade signal exists is the worker reviewer's call, but "silent drop, unchanged" is no longer an accurate row. |
|
||||
| TST-18 | Not started | Confirmed open — no `*HostedService*` tests under `src/ZB.MOM.WW.MxGateway.Tests`. |
|
||||
| TST-19 | Not started | Confirmed open — `FakeWorkerHarness.cs` still has no lockstep note / `WorkerPipeSession` pointer. Mitigation since: the harness's canned control replies are no longer the *only* automation (windows-x86 runs the real worker suite per push), but the drift risk the finding names is intact. |
|
||||
| TST-20 | Not started | Confirmed open — `run-client-e2e-tests.ps1:48` `DrainEveryTags = 15` workaround intact. |
|
||||
| TST-21 | Not started | Confirmed open — `Server/appsettings.json:10` daily rolling file sink, no size cap / retained-count. |
|
||||
| TST-22 | Not started | Confirmed open — the shape block (`docs/GatewayConfiguration.md:12-88`) is maintained (gained `GroupToTag`, `UntaggedSessionVisibility`, `RecentFaultLimit`/`RecentSessionLimit`) but still omits documented keys (`DisableLogin`, `AutoLoginUser`, `CookieName`, `Tls`, `Ldap`, `Security`, `Alarms:Fallback`, `Worker:WriteCompletionWaitMilliseconds`, …). The option **tables** below it are complete and current — the omissions are only in the illustrative block. |
|
||||
|
||||
## New-code / new-docs review since `4f5371f`
|
||||
|
||||
**CI workflow evolution — verified sound.** `ci.yml` at HEAD is a four-job pipeline whose comments are accurate down to the failure semantics. Details worth recording: the portable job now runs the .NET client *tests* (TST-24 wire tests included, `ci.yml:83-90`), installs pwsh and the pinned Check-4 generators explicitly (`:58-72`), and pins protoc 34.1; the java job's `git diff --exit-code` is the true drift gate after IPC-24 deleted the churn-revert; `run-windev-ci.sh` validates `CI_SHA` shape before it reaches a remote shell (`:46-49`), never echoes the key, and pins host keys from the committed `windev.known_hosts`; `windev-worker-ci.ps1` re-checks-out under the lock, verifies `HEAD == Sha`, `git clean -fdx -e ci.log`, and is deliberately PS5.1-safe. The nightly's issue-on-failure path uses the runner-resolvable `github.server_url` for the API call but `PUBLIC_SERVER_URL` for the human link — the exact defect Check 6 caught, fixed in `30c92e8`.
|
||||
|
||||
**check-codegen.ps1 —** now four checks with the Check 3 reverse sweep (`:98-103`, canonical proto missing a vendored copy fails — the drift class no in-repo build can surface, `711793f`) and Check 4 Windows support (`.exe` banner normalization in the per-client `generate-proto.ps1` probes, `c94c4d4`; PS5.1-safe native-arg quoting, `8ae0c2f`). The header comment (`:2-27`) is accurate.
|
||||
|
||||
**fca978d marker sweep → f82dac1 restoration — assessed complete on sampled evidence.** The sweep (2026-07-07, 203 files) deleted ~1,100 prose-bearing comment lines alongside the tracking markers; the 2026-08-18 re-read narrowed those to 816 residual lines, judged each, and restored 17 collateral deletions across 10 files (`f82dac1`, merged `8b3e3b3`). Spot-checks of four substantive deletions **outside** the restored set all found surviving equivalents at HEAD: the `SessionEventDistributor` mode-vs-count FailFast race rationale survives in `docs/Sessions.md:206` (including the "fixed mode rather than a live subscriber-count snapshot" decision) and `docs/GatewayConfiguration.md:159`; the `MxAccessStaSession` off-STA-deadlock affinity-guard rationale survives in `AlarmCommandHandler.cs:68-80`; the subtag source's own-COM-object isolation rationale survives in `LmxSubtagAlarmSource.cs` class remarks; the watchdog `HeartbeatStuckCeiling` rationale survives in `WorkerPipeSessionOptions.cs:35+`. No residual collateral damage found. The companion `d3ac527` audit (re-verifying 20 doc-only `Server-0xx` resolutions, re-fixing the two the sweep had silently reverted — Server-040 among them) closes the loop on the sweep's other damage class. This is a model recovery process.
|
||||
|
||||
**Doc currency at HEAD — high.** Sampled claims all match code: `docs/Sessions.md:17` pipe-name prose matches `SessionManager.cs:536` (`mxgw-{pid}-{uid}`, with the sun_path rationale); `docs/Sessions.md:248` owner-scoped attach matches `EventStreamService`; `docs/GatewayConfiguration.md` documents the new `WriteCompletionWaitMilliseconds` (`:121`, with the OtOpcUa 2 s budget interaction), `EventQueueCapacity` (`:122`), alarm poll/fetch ceilings incl. truncation semantics (`:435-436`), and the `Fallback` block (`:448-451`); `docs/GatewayTesting.md` is the standout — the windev sections (`:488-622`) record the 879 baseline, the two "environmental" failures that were test bugs, the load-sensitivity list, and a first-class post-mortem of the zero-buffer-pipe testhost hang with the structural fixes (`TestNamedPipe.CreateServer`, bounded `PipePair.WriteAsync`). `docs/Authentication.md` was made runnable-as-written (`2b2748b`); `docs/AlarmProbeFindings.md` states the probe questions and their evidence honestly (ack leg unavailable-by-configuration, GUID leg still assumed). `python3 code-reviews/regen-readme.py --check` passes at HEAD ("up to date and consistent").
|
||||
|
||||
**New-feature test placement — correct layers.** Alarm truncation / `snapshot_status`: proto-level flag plumbed with direct tests (`b9fb0dd` atomic snapshot+truncation read), gateway feed frame (`AlarmTruncationSignalTests.cs`), dashboard banner (`AlarmsPageTruncationBannerTests.cs` incl. the mid-truncation attach case, `f57a6ae`), and all five clients regenerated with render coverage (`4d4f443`). SEC-25 tag ACL: `DashboardSessionAclTests`, both subscribe seams covered (`EventsHubTests`, `SessionDetailsPageEventAclTests`), decision-table corners pinned (`7ec0b35`), plus live-LDAP ACL coverage (`1d8a4a6`). Drain cluster (WRK-21/IPC-23/IPC-30): worker-side byte-budget tests run per-push via windows-x86; the gateway side keeps the drain-to-empty walk inside the pipe-harness envelope (`a256560`). Write-completion correlation: worker executor tests (`b0e2b8b`, `c867aca`) plus gateway-side config plumb tests (`GatewayOptionsTests`, `WorkerProcessLauncherTests`). In-process wire tests (TST-24) exist for all five clients and run in CI (`docs/GatewayTesting.md:280-321`).
|
||||
|
||||
## New findings
|
||||
|
||||
### TST-31 — `glauth.md`'s pre-provisioned-users table contradicts the directory, and its wrong credential propagated into the live-test surface — **Medium** · documentation currency / test fixtures (promotes NEXT-10)
|
||||
|
||||
- **Files:** `glauth.md:55` (ReadOnly gid `5501` vs actual `5601`), `:65-78` (pre-provisioned users table: `readonly`/`readonly123` does not exist in `scadaproj/infra/glauth/config.toml`; `admin` carries the standard dev password, not `admin123`), `:124` (`Password: admin123` in a worked example); propagation: `docs/GatewayTesting.md:119` (`MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` default `admin123`) and `src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs:1432` (`?? "admin123"`, with `:517` attributing the identity to the GLAuth-bridged ArchestrA `admin`/`admin123`).
|
||||
- **Description:** Carried over from `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md` NEXT-10, which was deliberately deferred pending the OPC-UA group-taxonomy sweep and is still fully present at HEAD. The table was already the proven root cause of one defect (the NEXT-06 live-LDAP fixture drift, fixed 2026-08-07 — the *fixtures* were realigned but the table that misled them was not). The remaining propagation has a quiet coverage cost: the nightly live smoke's `WriteSecured` parity test authenticates with the defaulted `admin`/`admin123`, and because `AuthenticateUser` legitimately returns a real MXAccess *failure* reply on bad credentials (parity, not `InvalidRequest`), a wrong default does not fail the test — it silently degrades the WriteSecured leg from "authenticated round-trip" to "failure-reply shape check" every night. A reader also cannot tell which half of `glauth.md` to trust, since its dashboard section is correct.
|
||||
- **Recommendation:** Reconcile the table against `scadaproj/infra/glauth/config.toml` as one unit with the OPC-UA group taxonomy (the reason it was deferred), or — if that sweep stays parked — fix the two provably-wrong rows and the gid now and leave the taxonomy note. In the same change, re-derive the `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` default and the `WorkerLiveMxAccessSmokeTests` literal from whatever the rig's ArchestrA-bridged identity actually is, and make the live test log (not assert) whether `AuthenticateUser` succeeded, so a credential regression in the nightly is at least visible.
|
||||
|
||||
### TST-32 — The shared Gitea runner registration token (world-readable for ~5 months, proven live) still awaits its web-UI reset — **Medium** · CI/ops security
|
||||
|
||||
- **Files:** `docs/runbooks/TST-30-second-ci-runner.md:33-42` ("Follow-up 2026-08-07 — token hygiene"); `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md:24` (the pending-operator-action row).
|
||||
- **Description:** Both CI runners share one **instance-scope** registration token that sat world-readable in `/opt/gitea/docker-compose.yml` for roughly five months and is **still valid** — a probe registered runner id 6 with it and then deleted it. Host hygiene is done (token now a `0600` file mount, compose + backups locked down), but hygiene does not retire the credential: anyone who read it can register a rogue runner that receives this repo's jobs, including the `WINDEV_SSH_KEY` secret that grants shell on windev. Gitea 1.26.4 exposes no rotation via CLI or API (both are get-or-create), so the reset must be done in the admin web UI, then `/opt/gitea/runner_token` refreshed and the two token-bearing compose backups shredded. This has been a documented "pending operator action" since 2026-08-07 with no tracked finding forcing it to a close — precisely the state in which such actions rot.
|
||||
- **Recommendation:** Execute the reset per the runbook (minutes of work; re-registration of the two runners with the new token is the only follow-on). Track it here rather than only in a closed finding's runbook, and after the reset append the dated "Executed" note the runbook's own convention expects.
|
||||
|
||||
### TST-33 — `stillpending.md` has drifted into self-contradiction and now overstates what is pending — **Low** · repo hygiene / documentation currency
|
||||
|
||||
- **Files:** `stillpending.md:54` (reconnect "not safe to rely on end-to-end yet" — Tasks 13–15 shipped and were verified as cycle-2 TST-01/TST-02 with all-five-client consumers); `:56` (Phase 5 orphan reattach "**planned** (epic Phase 5, not started)" — directly contradicts the governance record TST-29 migrated to `docs/DesignDecisions.md:264-295`, which says **deferred, not planned**, and CLAUDE.md's invariant); `:111` ("WorkerLiveMxAccessSmokeTests.cs (6 `[LiveMxAccessFact]`)" — there are 8); `:118` ("Cross-language client↔gateway wire behavior — no per-client integration unit tests… All client wire behavior is unverified in default unit runs" — false since TST-24 closed 2026-08-10: all five clients have real-transport wire tests running in CI); `:150` (§8: per-session-ACL "still scheduled… not yet started") vs `:65` (§2: the same item "RESOLVED (2026-08-17)") — the file contradicts itself.
|
||||
- **Description:** The file is not a frozen snapshot — it has been annotated through 2026-08-17 — so readers reasonably treat it as live, and it is one of the two remaining root-level triage artifacts from TST-14. Its most dangerous staleness is `:56`: it re-asserts the "planned Phase 5 / `EnableOrphanReattach`" framing that TST-04/TST-29 spent two cycles extinguishing, in the exact file a future contributor greps when asking "what's still pending".
|
||||
- **Recommendation:** One currency pass: fix the five spots above (§2 reconnect → shipped; §2 Phase 5 → deferred-not-planned, pointing at DesignDecisions; §5 counts and the wire-test bullet; §8 ACL row → resolved, matching §2). Then either commit to keeping it current or stamp it "historical snapshot — see archreview trackers for live status" at the top, which is the cheaper honest option.
|
||||
|
||||
### TST-34 — The code-reviews README consistency check is "the intended hook for CI" but is wired into no CI job — **Low** · CI
|
||||
|
||||
- **Files:** `REVIEW-PROCESS.md:124-131` ("`regen-readme.py --check` exits non-zero if README.md is stale… The PowerShell wrapper `scripts/check-code-reviews-readme.ps1` runs that check and is the intended hook for CI or a pre-commit step"); `.gitea/workflows/ci.yml` (no reference to either script in any job); also `REVIEW-PROCESS.md:130-131` (the "bare `python3` alias resolves to the Windows Store stub — use `python`" note is Windows-only advice presented unconditionally; on the macOS tree `python` may be absent and `python3` is correct).
|
||||
- **Description:** The check passes at HEAD (verified this review), and the repo's process doc names CI as its intended home, but 13 months of CI evolution never picked it up — so a stale `code-reviews/README.md` or an inconsistent open-findings count merges silently until the next human runs the script. The portable job already installs Python 3.12, so the cost is one step.
|
||||
- **Recommendation:** Add a `Code-reviews README consistency` step to the `portable` job (`python code-reviews/regen-readme.py --check`), and qualify the interpreter note in REVIEW-PROCESS.md as Windows-specific (macOS/Linux: `python3`).
|
||||
|
||||
### TST-35 — The Windows-host run of the gateway (net10) suite is still manual-only, and its failure class recurred three times in August — **Low** · CI/coverage
|
||||
|
||||
- **Files:** `docs/GatewayTesting.md:488-498` ("The gateway suite… is not part of the CI Windows tier — `windows-x86` and `nightly-windev` run only the x86 Worker build and `Worker.Tests`. It is still run on windev by hand…"); evidence the class is real: `e2352d1` (two Windows-only gateway test bugs long dismissed as environmental), `bfcf829`/`02baf0c` (zero-buffer Windows pipe wedged the whole suite's testhost), `25f07f8` (SQLite pool sharing-violation on Windows-only cleanup).
|
||||
- **Description:** TST-25 restored the Worker tier, but the *gateway* suite's Windows-specific divergences (platform crypto formatting, Windows pipe buffer semantics, file-sharing rules) surface only when someone remembers the manual windev run. Every one of the three August finds was a real defect in test code that CI could have caught nightly; the suite's idle wall time on windev is ~31 s, and the nightly job already builds the full slnx there — the marginal cost of running the suite is small. Severity Low, not Medium: the same 879 tests run on Linux per push, the manual procedure is well documented with a current baseline, and known load-sensitivity (the suite flakes when run concurrently with the x86 tier, `GatewayTesting.md:530-555`) means naive stacking into the nightly needs care.
|
||||
- **Recommendation:** Extend `windev-worker-ci.ps1`'s `live` mode with a `dotnet test src/ZB.MOM.WW.MxGateway.Tests` step **after** the Worker tier finishes (the lock already serializes the box, which addresses the documented load sensitivity), and record the 879 expectation in the script log. If nightly wall time becomes a concern, a weekly cadence still beats "when someone remembers".
|
||||
|
||||
### TST-36 — Docs and CLAUDE.md refer to the "`event` scope"; the canonical scope string is `events:read` — **Low** · documentation
|
||||
|
||||
- **Files:** `CLAUDE.md:160` (twice: "Possessing the `event` scope…", "an `event`-scoped key"); `docs/Sessions.md:248` (twice); `docs/DesignDecisions.md:93`; `docs/Grpc.md:103` (mitigated — it parenthesizes `GatewayScopes.EventsRead`); canonical constants in `src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayScopes.cs:10` (`"events:read"`).
|
||||
- **Description:** `f2a422b` ("canonical scope strings in the two remaining stale lists") fixed the scope *lists* but left the `event`-scope shorthand in running prose. There is no `event` scope: an operator who takes the prose literally and runs `apikey create-key --scopes event` mints a key that cannot stream. CLAUDE.md even uses the correct `events:read` forty lines earlier (`:158`), so the same file teaches both spellings.
|
||||
- **Recommendation:** Mechanical sweep: replace "`event` scope" with "`events:read` scope" in the four prose sites (Grpc.md's parenthesized form is acceptable as-is). One grep verifies completion: `git grep -n '\`event\` scope'` over `CLAUDE.md docs/` returns nothing.
|
||||
|
||||
## Severity roll-up (new findings)
|
||||
|
||||
| Severity | Count | IDs |
|
||||
|---|---|---|
|
||||
| High | 0 | — |
|
||||
| Medium | 2 | TST-31, TST-32 |
|
||||
| Low | 4 | TST-33, TST-34, TST-35, TST-36 |
|
||||
|
||||
## Still good / preserved
|
||||
|
||||
- **The windev CI tier is the best piece of infrastructure this domain has reviewed in three cycles.** It shipped exactly per design and then got hardened by its own acceptance checks: the cleartext-key leak was found and fixed by storing the key base64 (with the masking rationale documented in three places), the bootstrap lock race was closed with an explicit `MXGW_CI_LOCK_HELD` handoff, the SHA is shape-validated before it reaches a remote shell, an unreachable windev fails red instead of wedging, and the nightly's failure path was itself tested to failure (Check 6) and fixed. `scripts/ci/README.md` records the bring-up, the acceptance checks with dated outcomes, and the degraded mode.
|
||||
- **The codegen guard now covers every generated surface in both directions:** descriptor set, `Contracts/Generated`, Rust vendored protos (including the reverse sweep for never-vendored canonical protos — drift no in-repo build can surface), Go/Python bindings with pinned generators that fail rather than skip when missing, and the Java aggregates via the java job's git-diff gate. Check 4 was additionally made Windows-runnable, so windev regen workflows use the same guard.
|
||||
- **The prior cycle's High (TST-25) is not just closed — its unlocks were followed through:** old TST-24 closed with real-transport wire tests in all five clients running in CI, old TST-05's scheduling half closed by the nightly, the EventBurst flake root-caused and deflaked (flush-edge sampling), the two "windev-environmental" failures exposed as test bugs, and the months-old full-suite testhost hang root-caused to a zero-buffer Windows pipe with structural fixes (`TestNamedPipe.CreateServer`, bounded `PipePair.WriteAsync`) plus a first-class post-mortem in `docs/GatewayTesting.md` — the windev baseline now equals macOS at 879.
|
||||
- **Documentation discipline visibly recovered from its one big self-inflicted wound.** The fca978d sweep's collateral damage was found by a systematic re-read (816 lines judged, 17 restorations), the doc-only `Server-0xx` resolutions were re-audited with the two regressions re-fixed (`d3ac527`), and every doc-currency spot-check this review ran (Sessions, GatewayConfiguration, GatewayTesting, Authentication, alarm probes, pipe naming, write-completion) matched code at HEAD. The two currency findings this cycle (TST-31, TST-33) are inherited debris, not new drift from the ~288-commit period.
|
||||
- **New features landed with tests at the right layers**: alarm truncation is asserted from the worker flag through the proto, the gateway feed frame, the dashboard banner (including mid-truncation attach), and all five client renders; the SEC-25 tag ACL is pinned at both subscribe seams with decision-table corners and live-LDAP coverage; `regen-readme.py --check` passes; `code-reviews/README.md` is consistent with the per-module findings at HEAD.
|
||||
@@ -0,0 +1,264 @@
|
||||
# MxAccessGateway — Remediation Plan & Tracking (2026-08-19 review)
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on gitea.dohertylan.com/dohertj2/mxaccessgw
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status here; file new work as an issue.
|
||||
>
|
||||
> Rows still open in this cycle's tracker were re-triaged and carried into the [2026-10-21 tracker](../../2026-10-21/remediation/00-tracking.md) (`R21-*` rows; cycle-1 rows promoted as `R21-O1`..`R21-O34`), whose `→ #N` pointers name the Gitea issues.
|
||||
|
||||
Source review: [`../00-overall.md`](../00-overall.md) (HEAD `8b3e3b3`, committed as `f4919ba`). Generated 2026-08-20.
|
||||
Tracks the 34 cycle-3 IDs (GWC-31+, WRK-32+, IPC-33+, SEC-37+, CLI-46+, TST-31+) plus the tracker-housekeeping actions the overall asked for. Cycle-1/2 trackers remain the record for their own IDs.
|
||||
|
||||
**Status legend:** `Not started` · `In progress` · `Done` · `Blocked (external)` · `Deferred (P2)` · `N/A`.
|
||||
|
||||
## Execution model
|
||||
|
||||
This plan is executed as **seven parallel batches**, each by one subagent working in an isolated git worktree on its own `fix/c3-<batch>` branch, merged back into `main` by the coordinator in the order listed (lowest conflict surface first). Every batch must: build the affected component(s) on macOS (`NonWindows.slnx` — the x86 worker cannot build here), run only the filtered tests it touches, update docs in the same commit, and cite finding IDs in commit messages (`fix(IPC-34): …`). Worker-side changes (batches E, F) are verified afterwards on windev via `windev-worker-ci.ps1 -Mode test`.
|
||||
|
||||
**Model assignment rationale:** `opus` for batches that change concurrency/authorization logic or require reasoning across several interacting files with regression tests; `sonnet` for contained, well-specified edits with a clear recipe (version bumps, doc reconciliation, a two-line worker fix); `haiku` for none — every batch either touches code invariants or needs judgement about stale prose, where the cost of a wrong mechanical sweep exceeds the saving.
|
||||
|
||||
| Batch | Branch | Findings | Model | Why this model | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| A | `fix/c3-client-versions` | CLI-46 | sonnet | Mechanical five-client bump + README note + one script guard; recipe is fully specified | Done |
|
||||
| B | `fix/c3-glauth-doc` | TST-31 | sonnet | Reconcile a table against one TOML source of truth, re-derive one default, change an assert to a log | Done |
|
||||
| C | `fix/c3-tracker-hygiene` | GWC-31 + housekeeping (close GWC-16/IPC-08/IPC-31, refresh TST-06/TST-17, register WRK-31) | sonnet | Cross-file comment/ID sweep needs care about what each ID *means*, but no logic changes | Done (`3619152`) |
|
||||
| D | `fix/c3-dashboard-acl` | SEC-40, SEC-41, GWC-38 | opus | Authorization semantics, fail-closed decisions, claims re-resolution; each needs a regression test | **Done** (`b177636` GWC-38, `2ef7b93` SEC-41+SEC-40, `8257a03` comment fixup; branch not merged/pushed) |
|
||||
| E | `fix/c3-ipc-boundaries` | IPC-33, IPC-39, IPC-34, IPC-35 | opus | Changes the WorkerClient command lifecycle, the options validator, and the worker drain budget; race-sensitive | **Done** (`577a0db` IPC-33, `a71663a` IPC-39, `b27035b` IPC-34, `ab41362` IPC-35; branch `fix/c3-ipc-boundaries`, not merged/pushed) — worker half verified on windev (`f2ab9cc`, Worker.Tests 524/524) |
|
||||
| F | `fix/c3-worker-residuals` | WRK-32, WRK-33 (+WRK-34 opportunistic) | sonnet | Small, precisely located worker edits + doc prose; cannot be built on macOS so the agent's job is a careful, minimal diff | Done (windev x86 build + Worker.Tests 524/524 green on `f2ab9cc`) |
|
||||
| G | `fix/c3-session-lifecycle` | GWC-32, GWC-34, GWC-37 | opus | Distributor/dispose lifecycle races and an alarm-monitor re-resolve path; needs lock-discipline reasoning | Done (`058d8d5`, `282a0e7`, `1932217`) |
|
||||
|
||||
**Out of scope for this execution (recorded, not dropped):**
|
||||
|
||||
| ID | Status | Why |
|
||||
|---|---|---|
|
||||
| TST-32 | Blocked (external) → #181 | Gitea runner registration-token reset is a web-UI action on the Gitea admin page; no repo change. Operator to-do. |
|
||||
| SEC-37 | Blocked (external) → #180 | Family-wide: TLS on the shared GLAuth lives in `scadaproj/infra/glauth/` (separate repo + docker host). Plan separately; after it lands, move the deployed service to `Production`. |
|
||||
| CLI-47/48/49/50, IPC-36/37, TST-35 | Done | Activated by the 2026-08-25 P2 continuation below and closed there: CLI-47/48/49/50 in batch K (`5cdb3da`), IPC-36/37 in batch J (`d6c5a11`), TST-35 in batch M (`cc143b5`). |
|
||||
| GWC-33/35, SEC-38/39 | Done | Closed by continuation batch I; regression counts and commits are recorded below. |
|
||||
| GWC-36, IPC-38, TST-33/34/36 | Done | Closed by continuation batch H; verification and commit are recorded below. |
|
||||
| WRK-34 | Done | Completed opportunistically in batch F (`3679e59`): watchdog faults use `CommandMethod=Watchdog` and carry the stuck correlation ID in the diagnostic message. Tracker correction recorded by the continuation audit. |
|
||||
|
||||
## Batch specifications
|
||||
|
||||
### A — CLI-46 client release-train bump (sonnet)
|
||||
- Bump .NET/Go/Python/Rust clients `0.2.0 → 0.2.1`, Java `0.2.1 → 0.2.2` in every version-bearing file (`pyproject.toml` + `version.py`, `Cargo.toml` workspace + any `version =` consumers, `Directory.Build.props`/csproj `<Version>`, Java `build.gradle`/`gradle.properties`, Go module is tag-driven — update any `Version` const if one exists). Follow `docs/ClientPackaging.md` for the authoritative file list.
|
||||
- `clients/python/README.md` + `docs/ClientPackaging.md`: note that published pypi 0.2.0 has a broken `connect()` (event-loop binding, fixed by `a8f86b5`) and is superseded by 0.2.1.
|
||||
- Add a PR-time check to `scripts/check-codegen.ps1` (or a new `scripts/check-client-versions.ps1` wired where the other checks run): fail if any `clients/<lang>` source differs from its last publish tag while the version constant still equals that tag's version. Document in `docs/ClientPackaging.md`.
|
||||
- Verify: `dotnet build clients/dotnet/...slnx`; `cargo check --workspace`; `python -m pytest` (clients/python); `JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test` then **revert** spurious generated-proto churn; `go build ./... && go test ./...`.
|
||||
|
||||
### B — TST-31 glauth.md + live-test credential (sonnet)
|
||||
- Reconcile `glauth.md` against `~/Desktop/scadaproj/infra/glauth/config.toml`: ReadOnly gid, the pre-provisioned users table (drop `readonly/readonly123`, fix `admin` password), the worked example at `:124`. Keep the OPC-UA taxonomy note.
|
||||
- Re-derive `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` default in `docs/GatewayTesting.md` and the `?? "admin123"` literal in `WorkerLiveMxAccessSmokeTests.cs` from the config; make the test **log** (not assert) whether `AuthenticateUser` succeeded.
|
||||
- Verify: `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx` (IntegrationTests compiles on mac? if excluded, note it).
|
||||
|
||||
### C — GWC-31 + tracker housekeeping (sonnet)
|
||||
- Sweep code comments citing unregistered `GWC-30`/`GWC-31` (perf pass) in `WorkerClient.cs`, `GatewayMetrics.cs`, `WorkerClientTests.cs`, worker `WorkerFrameReader/Writer.cs` to `PERF-nn` keyed to task numbers in `docs/plans/2026-08-15-perf-review-remediation.md`; add a PERF-ID table to that plan. Do not touch the genuine GWC-30 (frame-reader prefix) citations if any exist.
|
||||
- `archreview/REVIEW-PROCESS.md` (or wherever the ID rule lives): state that GWC/WRK/IPC/SEC/CLI/TST prefixes are reserved for archreview registers.
|
||||
- Cycle-2 tracker (`archreview/2026-07-12/remediation/00-tracking.md` + domain docs): GWC-16 → Won't fix (rationale from cycle-3 10-gateway-core.md), IPC-08 → Done (`7c1ea12`, residual IPC-34), IPC-31 → Done (superseded by GWC-28), refresh TST-06/TST-17 rows, register WRK-31 (interim ID burned by `e913dab`) so it is never reused.
|
||||
- Verify: `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`; `dotnet test --filter FullyQualifiedName~WorkerClientTests`.
|
||||
|
||||
### D — SEC-40, SEC-41, GWC-38 (opus)
|
||||
- SEC-41: `DashboardSessionAcl` re-resolves tags from `LdapGroupClaimType` claims via `DashboardGroupTagMapping.MapGroupsToTags` against current options (as `HubTokenService` does) instead of trusting login-stamped tag claims; keep stamped claims only as fallback when no group claims exist (document). Test: principal with stale tag claim + group whose grant was removed → denied.
|
||||
- SEC-40: decide **tighten**: `AlarmsHub` gets a dedicated policy requiring a real role claim (anonymous-loopback/Disabled bypass excluded) — and document in `docs/GatewayDashboardDesign.md` ACL section + `docs/GatewayConfiguration.md` GroupToTag row that alarm metadata remains galaxy-wide for authenticated Viewers (central monitor, no per-session provenance). Test for the policy.
|
||||
- GWC-38: `GatewayBrowseScopeProvider` throws `RpcException(PermissionDenied)` (and logs) when `Current` is null **and** API-key auth is enabled; unchanged when auth mode is Disabled. Test both branches.
|
||||
- Verify: build Server; `dotnet test --filter "FullyQualifiedName~DashboardSessionAcl|FullyQualifiedName~AlarmsHub|FullyQualifiedName~BrowseScope"` (adjust to real class names).
|
||||
|
||||
**Outcome (done, branch `fix/c3-dashboard-acl`, commits `b177636`, `2ef7b93`, `8257a03`; not merged, not pushed).** Decisions worth carrying forward:
|
||||
|
||||
- **SEC-41 fallback:** the ACL re-resolves from `mxgateway:ldap_group` claims when the principal has any, and falls back to the stamped `zb:dashboardtag` claims only when it has none. That is exactly the hub-token principal — `HubTokenService.Validate` rehydrates roles and tags but **not** groups, so re-resolution already happened at mint. The fallback cannot re-open the cookie gap, because `DashboardAuthenticator` stamps both claim kinds from one group list.
|
||||
- **SEC-40 requirement type:** new `AuthenticatedDashboardRoleRequirement` + `AuthenticatedDashboardRoleHandler` and a `MxGateway.Dashboard.Alarms` policy. `DashboardAuthorizationHandler` is `AuthorizationHandler<DashboardAuthorizationRequirement>`, so it is never invoked for the new type — the same structural device the Secrets UI's `secrets:manage` already relies on. Both hub schemes are retained on the policy.
|
||||
- **SEC-40 scope answer:** the `/alarms` Blazor page carries the SAME policy (it reads the central monitor in process — gating only the hub left the disclosure one navigation away), and the side-rail Alarms link is wrapped in an `AuthorizeView` on that policy. `AlarmsHubPublisher` is unchanged and stays unfiltered by design: audience is decided at hub admission and the feed carries no per-session provenance.
|
||||
- **GWC-38:** `PermissionDenied` + warning only when `Authentication:Mode=ApiKey`; `Mode=Disabled` still returns unscoped. `GalaxyRepositoryHostWiringTests` phase 1 was changed to push an *unconstrained identity* rather than none — that is production's real unscoped case.
|
||||
- New/changed tests: `AlarmFeedAuthorizationTests` (new, 7), `DashboardSessionAclTests` (+5), `GatewayBrowseScopeProviderTests` (+2), `SecretsNavRenderTests` (+2, and it must register the new policy — an unregistered policy name *throws* inside `AuthorizeView` rather than denying). Filtered run over the touched classes: 92 passed / 0 failed; `NonWindows.slnx` builds zero-warning.
|
||||
|
||||
### E — IPC-33, IPC-39, IPC-34, IPC-35 (opus)
|
||||
- IPC-33 option (a): `GatewayOptionsValidator` adds the upper coupling `Worker:MaxMessageBytes ≤ Protocol:MaxGrpcMessageBytes + 2×reserve`; worker `ResolveDrainReplyByteBudget` becomes `MaxMessageBytes − 2×DrainReplyFrameHeadroomBytes` (floor `MaxMessageBytes/2`); update `docs/GatewayConfiguration.md`, `docs/WorkerFrameProtocol.md`. Test: `GatewayOptionsTests` case for the new bound.
|
||||
- IPC-39: pre-check compares against `MaxMessageBytes − MaxSequenceEncodingBytes (11)`; fix the comment direction.
|
||||
- IPC-34: caller-cancellation arms call `TrySendCancelForAbandonedCommand` (renamed) gated on `removed`, skipped when `_stopCts` is the source; new `WorkerClientErrorCode.CallerCancelled` replacing the `GatewayShutdown` mislabel, mapped to `Cancelled` in the gRPC layer; update `docs/Grpc.md` mapping line, `gateway.md:913-919`, `docs/GatewayProcessDesign.md:596-602`. Test: twin of the timeout test driven by the caller token.
|
||||
- IPC-35: `ReadHandshakeEnvelopeAsync` special-cases `WorkerFault` → `WorkerClientException(WorkerFaulted, CreateWorkerFaultMessage(...))`. Test: fake writes `WorkerFault{MxaccessCreationFailed}` instead of `WorkerReady`, category text surfaces.
|
||||
- Verify: build Server; `dotnet test --filter "FullyQualifiedName~WorkerClientTests|FullyQualifiedName~GatewayOptions"`. Worker half → windev afterwards.
|
||||
|
||||
### F — WRK-32, WRK-33 (sonnet)
|
||||
- WRK-33: `ObserveFaultWhenever(ObserveCommandTaskAsync(commandTask));` at `WorkerPipeSession.cs:1235` (or catch-all-and-log in the observer).
|
||||
- WRK-32: one `pumpStep()` before capturing the `CurrentVersion` baseline in `MxAccessCommandExecutor.cs`; rewrite the executor doc comment (`:996-1003`) to state the recency-based contract; add the same to `docs/MxAccessWorkerInstanceDesign.md` write-completion section and the reply-field comment in `mxaccess_gateway.proto` (**comment-only proto edit → regenerate all generated artifacts in the same commit**, per the repo rule; if regen tooling is unavailable on macOS, skip the proto comment and say so).
|
||||
- WRK-34 if trivial: `CreateFault(StaHung, "Watchdog", …)` with the correlation id moved into the message.
|
||||
- Cannot build on macOS: keep diff minimal, net48-safe (no init-only/positional records). Verified on windev afterwards.
|
||||
|
||||
### G — GWC-32, GWC-34, GWC-37 (opus)
|
||||
- GWC-32: `DisposeAsync` transitions `_state` to `Closed` under `_syncRoot` before completing channels (or `if (_disposed || IsTerminalState()) return;` in `StageWorkerEvent`); update the XML doc to name all three completion paths. Test: dispose a Ready client mid-dispatch → no `ProtocolViolation` fault/metric.
|
||||
- GWC-34: `_eventDistributorDisposed` flag set under `_syncRoot` when nulling; `EnsureDistributorCreated` throws `SessionManagerException(SessionNotReady)` when set. Test: attach after dispose → throws, no silent hang.
|
||||
- GWC-37: on `ApplyProviderModeChangeAsync(toMode == Subtag)` re-resolve the watch list and re-subscribe; additionally surface "zero targets resolved while mode can need them" on the provider status (`Degraded` + reason). Update `docs/` alarm-subtag section. Test with the fake worker: GR discovery fails at start, succeeds at failover → watch list non-empty after failover.
|
||||
- Verify: build Server; `dotnet test --filter "FullyQualifiedName~WorkerClientTests|FullyQualifiedName~GatewaySession|FullyQualifiedName~AlarmMonitor|FullyQualifiedName~AlarmWatchList"`.
|
||||
- **Outcome (Done):** `058d8d5` GWC-32, `282a0e7` GWC-34, `1932217` GWC-37 on `fix/c3-session-lifecycle`. Filtered run 176/176 green; `NonWindows.slnx` clean.
|
||||
- GWC-37 **does not re-send** the watch-list on the failover edge. The worker's `AlarmCommandHandler.Subscribe` rejects a second `SubscribeAlarms` while subscribed, and the only expressible replacement (`UnsubscribeAlarms` + `SubscribeAlarms`) rebuilds `FailoverAlarmConsumer` — restarting it on the primary that just failed and discarding the warm standby — so it would flap the feed rather than repair it. The re-resolve happens and any drift is reported on the same status frame as the failover; a monitor restart is the supported way to apply a new list. Adding a watch-list-update worker command is a possible future item, not done here.
|
||||
- GWC-37 changes `IAlarmWatchListResolver.ResolveAsync` to return `AlarmWatchListResolution`, and gives `Degraded && Mode==ALARMMGR` a real meaning (empty/undiscoverable standby) with a new dashboard label `Alarm Manager (no subtag standby)`.
|
||||
- GWC-32's dispose path now also records `WorkerStopped("dispose")` via the existing once-latch, closing a `workers_running` gauge leak on the dispose-a-Ready-client path. GWC-12's double-dispose interlock got a natural home (the `_disposed` claim moved under `_syncRoot`) but is **not** claimed as fixed.
|
||||
|
||||
## Merge order
|
||||
C is merged **last** (its comment sweep touches the same files E and G edit). Order: A, B, F, D, G, E, C. After merge: full `NonWindows` build + one full gateway test run; then windev worker CI for E+F.
|
||||
|
||||
## Integration gate (2026-08-20)
|
||||
All seven branches merged into `main` (A `ee68cc3`, B `f59c34a`, C `f2ab9cc`, D `9f0c3fd`, E `cfbc995`, F `aa07497`, G `c6650c1`). Merged tree: `NonWindows.slnx` 0 warnings; full gateway suite **1183/1183** passed; windev `windev-worker-ci.ps1 -Sha f2ab9cc -Mode test`: x86 Worker build succeeded, `Worker.Tests` **524 passed / 11 skipped / 0 failed**. Follow-ups surfaced by the batches: a worker command to refresh a live alarm subscription's watch-list (would let GWC-37's re-resolve be applied, not just reported); WRK-32's proto reply-field comment deferred until the next proto regen wave; the five clients are bumped but not yet published.
|
||||
|
||||
## P2 continuation (planned 2026-08-25)
|
||||
|
||||
Current-state audit: refreshed `origin/main` and re-checked every deferred finding and integration-gate follow-up. Sixteen of the seventeen P2 findings remain open; WRK-34 was already completed by `3679e59`. All three follow-ups remain open. Exact target client coordinates are still absent from Gitea (HTTP 404), and `clients/go/v0.2.1` does not exist.
|
||||
|
||||
Approved design: [`../../../docs/plans/2026-08-25-cycle-3-p2-followups-design.md`](../../../docs/plans/2026-08-25-cycle-3-p2-followups-design.md) (`30548da`).
|
||||
|
||||
Execution plan: [`../../../docs/plans/2026-08-25-cycle-3-p2-followups.md`](../../../docs/plans/2026-08-25-cycle-3-p2-followups.md).
|
||||
|
||||
The continuation runs serially in the isolated `.worktrees/c3-p2-followups` worktree on `codex/c3-p2-followups`, with a tracker update at every batch boundary. Codex equivalents are selected from the implementation risk: `gpt-5.6-terra`/medium for contained documentation, CI, and client work; `gpt-5.6-sol`/high for concurrency, authorization, protocol, and live alarm lifecycle changes.
|
||||
|
||||
| Batch | Findings / follow-ups | Codex model | Status | Exit gate |
|
||||
|---|---|---|---|---|
|
||||
| H — tracker and currency | WRK-34 record, GWC-36, IPC-38, TST-33/34/36 | `gpt-5.6-terra` medium | Done | Currency greps clean; README check in portable CI; NonWindows build clean |
|
||||
| I — gateway correctness/security | GWC-33/35, SEC-38/39 | `gpt-5.6-sol` high | Done | Focused regression tests green; NonWindows build clean |
|
||||
| J — secondary contracts | IPC-36/37 | `gpt-5.6-sol` high | Done | Shared launch constants and descriptor parity tests green |
|
||||
| K — client alarm parity | CLI-47/48/49/50 | `gpt-5.6-terra` high | Done | All five client suites green |
|
||||
| L — live watch-list/proto wave | watch-list refresh follow-up + WRK-32 proto comment | `gpt-5.6-sol` high | Done | Codegen clean; portable alarm/contract tests green; windev Worker.Tests green |
|
||||
| M — Windows gateway CI | TST-35 | `gpt-5.6-terra` medium | Done | windev `live` mode proves Worker, gateway, full-slnx, and live smoke tiers |
|
||||
| N — integration and release | publish five clients + Contracts | `gpt-5.6-sol` high | Done | Full local/windev gates; merged `main`; registry coordinates and Go tag verified |
|
||||
|
||||
### Continuation tasklist
|
||||
|
||||
#### H — tracker and currency
|
||||
|
||||
- [x] Correct WRK-34 from Deferred to Done with commit `3679e59`.
|
||||
- [x] GWC-36: correct `EventSubscriberReplayAttachment.OldestAvailableSequence` semantics.
|
||||
- [x] IPC-38: add public `DrainEvents` behavior to `docs/Grpc.md` and correct the cycle-2 omission rationale.
|
||||
- [x] TST-33: reconcile the five stale/self-contradictory `stillpending.md` claims and mark its live-status boundary.
|
||||
- [x] TST-34: run `code-reviews/regen-readme.py --check` in portable CI and qualify the interpreter note.
|
||||
- [x] TST-36: replace prose ``event`` scope references with canonical ``events:read``.
|
||||
- [x] Record batch-H commit and verification evidence here.
|
||||
|
||||
Batch-H verification (2026-08-25): `python3 code-reviews/regen-readme.py --check`
|
||||
reported the generated README current and consistent; the TST-33 and live-document
|
||||
TST-36 stale-pattern greps returned no matches; `docs/Grpc.md` contains the public
|
||||
`DrainEvents` contract; and `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`
|
||||
succeeded with **0 warnings / 0 errors** in 1.59 s. Batch commit: `92aae28`.
|
||||
|
||||
#### I — gateway correctness and security
|
||||
|
||||
- [x] GWC-33: unregister terminal clients from the event-queue-depth gauge without shortening fault retention.
|
||||
- [x] GWC-35: repair acknowledged-to-active snapshot deltas with a Raise transition and instance identity.
|
||||
- [x] SEC-38: feed/consult the aggregate for valid key IDs that overflow the peer partition cap.
|
||||
- [x] SEC-39: prove a null effective key ID cannot reset the shared fallback partition.
|
||||
- [x] Run focused tests and clean NonWindows build; record counts and commits here.
|
||||
|
||||
Batch-I verification (2026-08-25): the combined `WorkerClientTests`,
|
||||
`GatewayAlarmMonitorAttachOrderTests`, and `ApiKeyFailureLimiterTests` filter passed
|
||||
**55/55** tests in 12 s; `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`
|
||||
succeeded with **0 warnings / 0 errors** in 3.42 s. Finding commits: GWC-33
|
||||
`b0156c6`, GWC-35 `7807201`, and SEC-38/SEC-39 `1875426`.
|
||||
|
||||
#### J — secondary contracts
|
||||
|
||||
- [x] IPC-36: centralize all six worker-launch environment names in Contracts and consume them on both sides.
|
||||
- [x] IPC-36: document variable/config/default mapping and test launcher completeness.
|
||||
- [x] IPC-37: add normalized retained-vs-served Galaxy descriptor parity test.
|
||||
- [x] Run focused tests and clean NonWindows build; record counts and commits here.
|
||||
|
||||
Batch-J verification (2026-08-25): the combined `WorkerProcessLauncherTests` and
|
||||
`GalaxyContractParityTests` filter passed **10/10** tests in 1 s; the descriptor
|
||||
mutation proof identified the changed protobuf message; and `dotnet build
|
||||
src/ZB.MOM.WW.MxGateway.NonWindows.slnx` succeeded with **0 warnings / 0
|
||||
errors** in 2.20 s. IPC-36/IPC-37 commit: `d6c5a11`.
|
||||
|
||||
#### K — client alarm-feed parity
|
||||
|
||||
- [x] CLI-47: make .NET text status flags explicit and document deliberate JSON differences.
|
||||
- [x] CLI-48: test provider/snapshot status arms in .NET, Go, and Java; add Go stream-alarm scaffold.
|
||||
- [x] CLI-49: export Go aliases for `AlarmProviderStatus` and `AlarmSnapshotStatus`; fix five-arm documentation.
|
||||
- [x] CLI-50: stream Python alarm rows incrementally with an unbounded default and bounded opt-in.
|
||||
- [x] Run .NET, Go, Python, Rust, and Java client suites; record counts and commits here.
|
||||
|
||||
Batch-K verification (2026-08-25): `dotnet test
|
||||
clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` passed **134/134** portable
|
||||
tests (1 live test skipped); `go test ./...` passed both tested packages;
|
||||
`python -m pytest` passed **169/169** portable tests (1 opt-in TLS test
|
||||
skipped); `cargo test --workspace` passed **108/108** tests; and
|
||||
`JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test` passed **132/132** tests.
|
||||
CLI-47/CLI-48/CLI-49/CLI-50 commit: `5cdb3da`.
|
||||
|
||||
#### L — live watch-list refresh and protobuf wave
|
||||
|
||||
- [x] Add explicit fallback-enabled subscription semantics for an initially empty discovered list.
|
||||
- [x] Add and round-trip `UpdateAlarmWatchListCommand` and its `MxCommandKind`.
|
||||
- [x] Route the command through the worker executor and alarm handler on the STA.
|
||||
- [x] Build/subscribe/attach a replacement standby before swap; preserve the old standby on failure.
|
||||
- [x] Preserve current primary/subtag mode during a successful replacement.
|
||||
- [x] Re-resolve on the existing gateway reconciliation cadence and send only material changes/recovery.
|
||||
- [x] Refresh immediately at failover edge, update acknowledged fingerprint only after worker OK, and reconcile after success.
|
||||
- [x] Expand WRK-32's public reply-field comment to the sequential stale-callback/recency case.
|
||||
- [x] Replace IPC-36's internal env-var name in the proto comment with the public configuration key.
|
||||
- [x] Regenerate Contracts and all client generated trees once; run codegen and all affected suites.
|
||||
- [x] Verify x86 Worker build and Worker.Tests on windev; record SHA/counts here.
|
||||
|
||||
Batch-L verification (2026-08-25): `pwsh scripts/check-codegen.ps1` passed all
|
||||
four descriptor/C#/Rust/Go/Python freshness checks, and Java
|
||||
`checkGeneratedClean` passed. The exact portable alarm/contract/descriptor
|
||||
filter passed **77/77**; `NonWindows.slnx` built with **0 warnings / 0 errors**;
|
||||
the .NET, Go, Python, Rust, and Java client suites were green (respectively
|
||||
**134 passed / 1 live skip**, both Go packages, **169 passed / 1 opt-in TLS
|
||||
skip**, **108 passed**, and **132 passed**). Windev `test` mode at
|
||||
`986c6c0ce4f02c62b4f59ec44780de5af6d291b8` built the x86/net48 Worker with
|
||||
**0 warnings / 0 errors** and passed Worker.Tests **529 passed / 11 opt-in
|
||||
skips / 0 failed**. Feature/proto commit: `484028e`; Batch-J x86 compile
|
||||
follow-up: `986c6c0`.
|
||||
|
||||
#### M — Windows gateway CI
|
||||
|
||||
- [x] TST-35: add the serialized net10 gateway suite to windev `live` mode.
|
||||
- [x] Update `docs/GatewayTesting.md` and `scripts/ci/README.md` with order/failure semantics.
|
||||
- [x] Execute `live` mode on windev and record Worker, gateway, full-slnx, and live-smoke evidence here.
|
||||
|
||||
Batch-M verification (2026-08-25): windev `live` mode at exact commit
|
||||
`cc143b52cf04be0b2f08350922a987b2e250bc1d` completed the serialized sequence in
|
||||
**4m45s** with remote exit code 0. The x86/net48 Worker build succeeded with **0
|
||||
warnings / 0 errors** in 22.59 s; Worker.Tests passed **529**, skipped **11**
|
||||
opt-in probes, and failed **0**; the full Windows `slnx` build succeeded with **0
|
||||
warnings / 0 errors** in 43.24 s; the net10 gateway suite passed **1193/1193**
|
||||
in 53 s; and the live-MXAccess smoke passed **8/8** in 1m46s. TST-35 commit:
|
||||
`cc143b5`.
|
||||
|
||||
#### N — integration and release
|
||||
|
||||
- [x] Run `git diff --check`, codegen, client-version, README, NonWindows build, and full gateway suite gates.
|
||||
- [x] Run all five client suites on the final candidate.
|
||||
- [x] Run final windev `live` gate at the exact candidate SHA.
|
||||
- [x] Merge/push the verified continuation to `main`; wait for green CI.
|
||||
- [x] Re-query all package coordinates and Go tag immediately before publish; abort on collision.
|
||||
- [x] Publish .NET Client + Contracts 0.2.1, Python 0.2.1, Rust 0.2.1, Java 0.2.2.
|
||||
- [x] Create/push `clients/go/v0.2.1` at the exact release commit.
|
||||
- [x] Verify every registry coordinate and Go module/tag, update `docs/ClientPackaging.md`, and record immutable release evidence here.
|
||||
|
||||
Batch-N portable verification (2026-08-25): `git diff --check`, codegen freshness,
|
||||
client-version freshness, and the generated README check all passed without changing
|
||||
the worktree. `NonWindows.slnx` built with **0 warnings / 0 errors**; the gateway
|
||||
suite passed **1193/1193**, and the portable integration suite passed **5** with
|
||||
**19** live-only skips. The final .NET, Go, Python, Rust, and Java client gates were
|
||||
green (respectively **134 passed / 1 live skip**, all Go packages built/tested with
|
||||
clean `gofmt`, **169 passed / 1 opt-in TLS skip** from a clean virtual environment,
|
||||
**108 passed** plus clean `cargo fmt`/Clippy, and **132 passed / 0 failed** with the
|
||||
generated Java tree unchanged).
|
||||
|
||||
Batch-N integration/release verification (2026-08-25): `main` was integrated and
|
||||
released at exact commit `5776021970cbbe59813ba57579abe00d9fc61a46`; Gitea CI
|
||||
run **783** concluded success. Windev `live` mode at that SHA built the x86/net48
|
||||
Worker with **0 warnings / 0 errors**, passed Worker.Tests **529 passed / 11
|
||||
opt-in skips / 0 failed**, built the full Windows solution with **0 warnings / 0
|
||||
errors**, passed the Windows gateway suite **1193/1193**, and passed the live
|
||||
MXAccess smoke **8/8**. One preceding full-suite attempt hit a transient fake-worker
|
||||
disconnect in the reconnect/replay test; the test then passed **50/50** isolated
|
||||
macOS runs and **20/20** isolated windev runs before the clean full-gate pass.
|
||||
|
||||
Immediately before publication, all five package coordinates returned HTTP 404
|
||||
and `clients/go/v0.2.1` was absent locally/remotely. The published coordinates are
|
||||
NuGet `ZB.MOM.WW.MxGateway.Client` **0.2.1** and
|
||||
`ZB.MOM.WW.MxGateway.Contracts` **0.2.1**, PyPI
|
||||
`zb-mom-ww-mxaccess-gateway-client` **0.2.1**, Cargo
|
||||
`zb-mom-ww-mxgateway-client` **0.2.1**, and Maven
|
||||
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.2**. Each exact Gitea
|
||||
package API coordinate returned HTTP 200 after publication. Annotated Go tag
|
||||
`clients/go/v0.2.1` resolves locally, remotely, and through `go list -m` to
|
||||
`5776021970cbbe59813ba57579abe00d9fc61a46`.
|
||||
@@ -0,0 +1,27 @@
|
||||
# MxAccessGateway — System Review (2026-10-21)
|
||||
|
||||
Summary of the fourth review cycle. Per-finding status is in [`remediation/00-tracking.md`](remediation/00-tracking.md); the per-domain finding text is not kept in this directory, so the tracker rows carry the description.
|
||||
|
||||
## Dimensions
|
||||
|
||||
Six reviewers each took one dimension of the system.
|
||||
|
||||
| Dimension | Reviewed | Outcome |
|
||||
|---|---|---|
|
||||
| Sessions | Session lifecycle, sweep, shutdown, lease, metrics accounting (`GatewaySession`, `SessionManager`) | One High: a kill or shutdown during worker startup orphaned the worker. Nine findings, all fixed in stream A. Replay-ring byte cap remains open (low) |
|
||||
| Worker | STA runtime, alarm polling, `ReadBulk`, caches, process exit paths (`ZB.MOM.WW.MxGateway.Worker`) | Eight findings fixed on `fix/review-2026-10-21-e`, not yet merged; four deferred (one needs a parity probe, one is a security tradeoff) |
|
||||
| gRPC | Interceptor coverage, audit, alarm feed, correlation ids, `/metrics` | Eleven findings fixed. `/metrics` authentication is opt-in |
|
||||
| Dashboard | Hub fan-out, browse, snapshot feed, health status, design doc | Ten findings fixed; three low races left open |
|
||||
| Clients | Five language clients: ack retry, buffers, dispose, typed diagnostics | All fixed. The Go `go vet` copylocks error in a test predates the review and remains |
|
||||
| Docs vs code | Proto comments, `stillpending.md`, tracker headers, untested helpers | In progress in stream L |
|
||||
|
||||
## Cross-cutting observations
|
||||
|
||||
- The two-process lifecycle is the most failure-prone area: most of the nine session findings are races between open, close, kill and shutdown. Each fix added a regression test; the pattern to keep is that every exit path accounts for the session exactly once.
|
||||
- Silent failure was the recurring theme in the worker (STA death, alarm-poll failure, missing unhandled-exception hook). Each now ends in a typed fault or a distinct exit code.
|
||||
- Bounded fan-out was the recurring theme in the dashboard: every hub send now has a bound, a timeout or a drop policy.
|
||||
- Three external items are outside this repository: shared GLAuth TLS, the Gitea runner token, and an OtOpcUa session leak.
|
||||
|
||||
## Cycle-1 backlog
|
||||
|
||||
The review also re-triaged 68 first-cycle findings that no later tracker had re-listed: 21 are done, 13 are closed as won't fix with a reason, and 34 remain open and are promoted as `R21-O1` to `R21-O34`. The highest-weight open items are the missing reply to a command that races worker shutdown, the worker pipe without `CurrentUserOnly`, the no-op startup probe with retry options that cannot retry, the redactor gap for `WriteSecuredBulk`/`WriteSecured2Bulk`, the .NET retry budget that a `DeadlineExceeded` consumes, and the Go client's missing typed auth errors and strict-TLS flag.
|
||||
@@ -0,0 +1,227 @@
|
||||
# MxAccessGateway — Remediation Tracking (2026-10-21 system review)
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on gitea.dohertylan.com/dohertj2/mxaccessgw
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status here; file new work as an issue.
|
||||
|
||||
Source review: [`../00-overall.md`](../00-overall.md) (summary of the six review dimensions). Generated 2026-10-22 from the review outcomes and the integration branch `fix/review-2026-10-21`.
|
||||
Tracks the findings of the 2026-10-21 system review and the cycle-1 findings that no later tracker had re-listed. The earlier trackers remain the record for their own IDs: [cycle 1](../../remediation/00-tracking.md), [cycle 2](../../2026-07-12/remediation/00-tracking.md), [cycle 3](../../2026-08-19/remediation/00-tracking.md).
|
||||
|
||||
## How to use this document
|
||||
|
||||
- IDs in this cycle use the prefix `R21-` so they cannot collide with the `GWC`/`WRK`/`IPC`/`SEC`/`CLI`/`TST` registers of earlier cycles. `R21-S` is sessions, `R21-W` is the worker client, `R21-G` is the gRPC surface, `R21-D` is the dashboard, `R21-K` is the Windows worker, `R21-C*` are the client libraries, `R21-L` is docs and test gaps, `R21-X` is external, and `R21-O` is a cycle-1 finding re-triaged as still open.
|
||||
- Where a finding is the same as an earlier-cycle ID, the description names it (for example `R21-G11` is `SEC-14`).
|
||||
- The review's own severity table is not in the repository. Only `R21-S1` carries a severity from the review (High); every other severity here is the triage estimate from the finding's impact and is open to correction.
|
||||
- The **Status** column is the single source of truth. Update it in the same change that lands the fix, and cite the finding ID in commit messages. *(Superseded 2026-11-06: status now lives in the Gitea issue named by each `→ #N` pointer; the Status column is frozen as of the migration.)*
|
||||
|
||||
**Status legend:** `Done` · `Done (merge 2013573)` · `Done (merge 2fad124)` · `Deferred` · `Won't fix` · `Open` · `Blocked (external)`.
|
||||
|
||||
## Execution model
|
||||
|
||||
The review was remediated by ten parallel streams on branches `fix/review-2026-10-21-{a..j}`, merged into the integration branch `fix/review-2026-10-21`, plus stream K (this tracker triage) and stream L (docs and test gaps).
|
||||
|
||||
| Stream | Branch | Scope | Status |
|
||||
|---|---|---|---|
|
||||
| A | `fix/review-2026-10-21-a` | Sessions (`R21-S*`) | Done, merged (`7f0720f`) |
|
||||
| B | `fix/review-2026-10-21-b` | WorkerClient (`R21-W1`) | Done, merged (`81d4cd8`) |
|
||||
| C | `fix/review-2026-10-21-c` | gRPC surface (`R21-G*`) | Done, merged (`3f9448f`) |
|
||||
| D | `fix/review-2026-10-21-d` | Dashboard (`R21-D*`) | Done, merged (`8212b0e`) |
|
||||
| E | `fix/review-2026-10-21-e` | Worker (`R21-K*`) | Done (merge 2013573) — `7618caf`, follow-up `38fbbde` |
|
||||
| F | `fix/review-2026-10-21-f` | .NET client | Done, merged (`3a1ee28`) |
|
||||
| G | `fix/review-2026-10-21-g` | Java client | Done, merged (`c18566b`) |
|
||||
| H | `fix/review-2026-10-21-h` | Go client | Done, merged (`7636573`) |
|
||||
| I | `fix/review-2026-10-21-i` | Rust client | Done, merged (`b2a7fc5`) |
|
||||
| J | `fix/review-2026-10-21-j` | Python client | Done, merged (`29d4e51`) |
|
||||
| K | `fix/review-2026-10-21-k` | Tracker triage (this document) | Done |
|
||||
| L | (stream L branch) | Proto comments, docs, test gaps | Done (merge 2fad124) |
|
||||
|
||||
Worker changes (stream E) need the windev x86 build and `Worker.Tests` run after merge; stream E's own live facts passed on windev. → #169 (merge to origin `main` + windev Worker gate)
|
||||
|
||||
## Sessions (stream A)
|
||||
|
||||
| ID | Sev | Area | Description | Status | Commit / stream | Reason / note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R21-S1 | High | Sessions | A kill or shutdown during worker startup orphaned the worker | Done | A: `87f23d9`, `ec9f264` | Gated attach, discard on any exception, `OpenRecorded`; a late worker is never orphaned |
|
||||
| R21-S2 | Medium | Sessions | Sweep-driven closes skipped `SessionClosed()`, so the open-sessions gauge drifted | Done → #168 | A: `09666cc` | |
|
||||
| R21-S3 | Medium | Sessions | `OpenSession` was still accepted while the host was stopping | Done | A: `37ab629` | `GatewayStopping` returns `Unavailable`; `ShutdownAsync` re-snapshots until the registry is empty |
|
||||
| R21-S4 | Low | Sessions | A shutdown timeout whose kill succeeded was reported as `CloseFailed` | Done | A: `2b165cd` | Now `Closed`, with `WorkerShutdownFailure` recorded |
|
||||
| R21-S5 | Low | Sessions | A failed open left a metrics entry behind | Done | A: `87f23d9` | Open rollback goes through the removal path |
|
||||
| R21-S6 | Medium | Sessions | Sessions that never stream pinned a worker for the whole lease | Done → #167 | A: `285ef64`, `ec9f264` | Config-gated `UnattachedSessionGraceSeconds` (default 0 = off); `ExtendLease` deleted; internal subscribers count as attached; idle clock starts at Ready |
|
||||
| R21-S7 | Low | Sessions | A kill racing a close double-decremented the gauge | Done | A: `ec9f264` | Found in review of stream A |
|
||||
| R21-S8 | Low | Sessions | A sweep that lost the race to a close was reported as a failure | Done | A: `ec9f264` | Found in review of stream A |
|
||||
| R21-S9 | Medium | Sessions | After `ShutdownWorker` the session stayed `Ready` | Done | A: `cc1e895` | The session faults and the sweep reaps it. Found live |
|
||||
| R21-S10 | Low | Sessions | The replay ring has a count cap but no byte cap | Open → #172 | — | Documented as bounded by count. Next step: add a byte cap if large-array events make the count cap insufficient |
|
||||
|
||||
## WorkerClient (stream B)
|
||||
|
||||
| ID | Sev | Area | Description | Status | Commit / stream | Reason / note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R21-W1 | Medium | WorkerClient | `ShutdownAsync` could block past `ShutdownTimeout` | Done | B: `b0100e7` | The timeout CTS now covers the envelope enqueue |
|
||||
|
||||
## gRPC surface (stream C)
|
||||
|
||||
| ID | Sev | Area | Description | Status | Commit / stream | Reason / note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R21-G1 | Medium | gRPC | Denied bulk commands flooded the audit channel | Done | C: `ca0cf19` | One aggregated event per bulk call |
|
||||
| R21-G2 | Medium | gRPC | The authorization interceptor did not cover client-streaming or duplex calls (cycle-1 `SEC-17`) | Done | C: `19cc74f` | Both fail closed to the admin scope |
|
||||
| R21-G3 | Low | gRPC | `StreamAlarms` overflow returned `Unavailable` | Done | C: `0b4a168` | Now `ResourceExhausted` |
|
||||
| R21-G4 | Low | gRPC | `StreamAlarms` cloned the snapshot under the lock on every attach | Done | C: `0b4a168` | Memoized projection |
|
||||
| R21-G5 | Low | gRPC | `WriteAsync` was not given the call's cancellation token | Done | C: `0b4a168` | |
|
||||
| R21-G6 | Low | gRPC | `correlation_id` handling was inconsistent across replies | Done | C: `0b4a168`, `82a856b` | `client_correlation_id` is echoed; a Debug log records the client/envelope pair |
|
||||
| R21-G7 | Low | gRPC | The `StreamDisconnected` reason was a constant (cycle-1 `GWC-22`) | Done | C: `6ae5ed9` | |
|
||||
| R21-G8 | Low | Security | `CachingApiKeyVerifier` index pruning and single-flight misses | Done | C: `ed3a2c9` | |
|
||||
| R21-G9 | Low | gRPC | `ConstraintEnforcer` allocated a `Task` per item | Done | C: `ca0cf19` | `ValueTask` |
|
||||
| R21-G10 | Low | Metrics | `Enum.ToString` was called per event | Done | C: `68d6b5f` | `MxEventFamilyNames` cache |
|
||||
| R21-G11 | Medium | Security | `/metrics` was unauthenticated (cycle-1 `SEC-14`) | Done | C: `65bca80`, `82a856b` | Opt-in `Metrics:RequireAuthentication` (admin key, or dashboard Admin/Viewer cookie); `DisableLogin` does not bypass it; `/health` stays open by design |
|
||||
| R21-G12 | Low | Metrics | The `/metrics` per-peer limiter is keyed by IP while the gRPC limiter is keyed by connection | Won't fix | — | Documented difference; the two surfaces have different peer identities |
|
||||
| R21-G13 | Low | Security | The verifier generation guard depends on key-id case | Open → #173 | — | Latent only. Next step: normalize the key id (ordinal-ignore-case) at the guard and add a test |
|
||||
|
||||
## Dashboard (stream D)
|
||||
|
||||
| ID | Sev | Area | Description | Status | Commit / stream | Reason / note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R21-D1 | Medium | Dashboard | Unbounded events-hub sends | Done | D: `f1db002`, `bd7abf4` | One drop-oldest pump per session; a lock re-check race was fixed in review |
|
||||
| R21-D2 | Medium | Dashboard | One slow client stalled the alarms hub | Done | D: `3688fa1` | 2 s send timeout plus an `AlarmFeedReset` marker. Tradeoff documented: an abandoned send is not followed by a reset |
|
||||
| R21-D3 | Low | Dashboard | Browse search ran on every keystroke | Done | D: `db78336` | Debounce, off-dispatcher, indexed |
|
||||
| R21-D4 | Medium | Dashboard | The full snapshot plus Configuration went out every second | Done | D: `6733519` | Configuration is sent once per connection; sends have a timeout |
|
||||
| R21-D5 | Low | Dashboard | `AlarmsPage` re-sorted twice per render | Done | D: `2a7d6fa` | `DashboardAlarmView` memo |
|
||||
| R21-D6 | Low | Dashboard | `BrowsePage` disposal was unbounded | Done | D: `db78336` | |
|
||||
| R21-D7 | Low | Dashboard | Two viewers thrashed the advise LRU | Done | D: `5d4aefc` | Per-viewer share |
|
||||
| R21-D8 | Low | Dashboard | Disconnected circuits kept polling | Done | D: `db78336` | 30 s retention plus a connection tracker |
|
||||
| R21-D9 | Low | Dashboard | `GatewayStatus` was hardcoded (cycle-1 `SEC-27`) | Done | D: `6733519` | Derived from health checks |
|
||||
| R21-D10 | Low | Docs | `docs/GatewayDashboardDesign.md` described counters that do not exist | Done | D: `2c963b7` | Doc rewritten to match the shipped pages |
|
||||
| R21-D11 | Low | Dashboard | `BrowsePage` search CTS cancel/dispose race | Open → #174 | — | Only a caught `ObjectDisposedException`. Next step: swap-and-dispose under a lock when the page is next touched |
|
||||
| R21-D12 | Low | Dashboard | Cached health status is stale while the publisher is idle | Open → #174 | — | Next step: refresh on first subscribe |
|
||||
| R21-D13 | Low | Dashboard | Hub pumps are not awaited on broadcaster dispose | Open → #174 | — | Next step: await the pumps with a bounded timeout in `DisposeAsync` |
|
||||
|
||||
## Worker (stream E)
|
||||
|
||||
All rows are on `fix/review-2026-10-21-e` (`7618caf`, follow-up `38fbbde`), not yet merged. After merge, run `windev-worker-ci.ps1 -Mode test` and update these rows to plain `Done`. → #169
|
||||
|
||||
| ID | Sev | Area | Description | Status | Commit / stream | Reason / note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R21-K1 | Medium | Worker | The write-completion cache and value cache were never evicted | Done (merge 2013573) | E: `7618caf`, `38fbbde` | Ghost-entry eviction on `AddItem` added in the follow-up |
|
||||
| R21-K2 | Medium | Worker | `ReadBulk` was serial (N x timeout) | Done (merge 2013573) | E: `7618caf`, `38fbbde` | Shared deadline; dedupe and periodic message pumping in the follow-up |
|
||||
| R21-K3 | Medium | Worker | One alarm-poll failure faulted the session (cycle-1 `WRK-05`) | Done (merge 2013573) | E: `7618caf` | Consecutive-failure threshold of 3 |
|
||||
| R21-K4 | Low | Worker | The primary alarm consumer leaked when the standby build failed | Done (merge 2013573) | E: `7618caf` | |
|
||||
| R21-K5 | Low | Worker | `StaRuntime` drained the whole queue before pumping messages | Done (merge 2013573) | E: `7618caf` | |
|
||||
| R21-K6 | Medium | Worker | STA thread death after start was silent (cycle-1 `WRK-02`) | Done (merge 2013573) | E: `7618caf` | `LoopFault` becomes a `StaHung` WorkerFault |
|
||||
| R21-K7 | Low | Worker | No unhandled-exception hook (cycle-1 `WRK-09`) | Done (merge 2013573) | E: `7618caf` | Exit code 7 |
|
||||
| R21-K8 | Low | Worker | `RETRIGGER` handling was undocumented | Done (merge 2fad124) | stream L | Documented as reserved, never synthesized; the proto comment is stream L's |
|
||||
| R21-K9 | Medium | Tests | Live control-command facts for the worker (cycle-1 `TST-05` residual) | Done (merge 2013573) | E: `069972a`, `4ea3672` | Facts added and passing on windev |
|
||||
| R21-K10 | Low | Worker | Unregister-first shutdown cleanup | Deferred → #175 | — | Needs a parity probe against the installed MXAccess before it can change cleanup order |
|
||||
| R21-K11 | Medium | Security | A per-operator `AuthenticateUser` cache | Deferred → #176 | — | Security tradeoff (caching credentials' outcome); decide with the security owner before building |
|
||||
| R21-K12 | Low | Worker | Batch task-completion-source allocation rework | Deferred → #177 | — | Low value |
|
||||
| R21-K13 | Low | Worker | The alarm poll builds a full-BSTR `XmlDocument` every 500 ms | Deferred → #177 | — | Steady GC cost, not a correctness issue; revisit if profiling flags it |
|
||||
|
||||
## Clients (streams F to J)
|
||||
|
||||
| ID | Sev | Area | Description | Status | Commit / stream | Reason / note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R21-CN1 | Medium | .NET client | Acknowledge retried on transient failure | Done | F: `6f0b408` | No ack retry |
|
||||
| R21-CN2 | Medium | .NET client | Client always owned its channel | Done | F: `6f0b408` | Caller-owned channel constructors |
|
||||
| R21-CN3 | Medium | .NET client | `DisposeAsync` could throw or block (cycle-1 `CLI-06`) | Done | F: `6f0b408` | Safe, best-effort dispose |
|
||||
| R21-CN4 | Low | .NET client | No typed Ping/GetSessionState/GetWorkerInfo/DrainEvents | Done | F: `6f0b408` | |
|
||||
| R21-CN5 | Medium | .NET client | A session could not be re-attached by id (cycle-1 `CLI-05`) | Done | F: `6f0b408` | `AttachSession` |
|
||||
| R21-CJ1 | Medium | Java client | Event queue was fixed at 16 | Done | G: `48c9323` | Configurable, default 1024 |
|
||||
| R21-CJ2 | Medium | Java client | Overflow dropped buffered events | Done | G: `48c9323` | Overflow keeps buffered events |
|
||||
| R21-CJ3 | Low | Java client | Interrupt did not cancel the call | Done | G: `48c9323` | |
|
||||
| R21-CJ4 | Low | Java client | No typed diagnostics | Done | G: `48c9323` | |
|
||||
| R21-CG1 | Medium | Go client | Event buffer fixed at 16 | Done | H: `a3a7bd2` | `EventBufferSize` option, default 1024 |
|
||||
| R21-CG2 | Medium | Go client | Deprecated `DialContext` + `WithBlock` (cycle-1 `CLI-10`) | Done | H: `a3a7bd2` | `grpc.NewClient` |
|
||||
| R21-CG3 | Low | Go client | No typed diagnostics | Done | H: `a3a7bd2` | |
|
||||
| R21-CG4 | Low | Go client | `go vet` copylocks error in `types_alias_test.go` | Done | see commit "fix(go-client): go vet copylocks" | The test declares pointer types; `go vet ./...` is clean |
|
||||
| R21-CR1 | Medium | Rust client | Blocking file read on an async path (CA file) | Done | I: `0345726` | Async read |
|
||||
| R21-CR2 | Low | Rust client | No typed diagnostics | Done | I: `0345726` | |
|
||||
| R21-CP1 | Low | Python client | No typed diagnostics | Done | J: `99347d3`, `d716dec` | The client already had no ack retry, no event buffer, and cancels on exit |
|
||||
|
||||
## Docs and test gaps (stream L)
|
||||
|
||||
| ID | Sev | Area | Description | Status | Commit / stream | Reason / note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R21-L1 | Low | Docs | `operator_full_name` is silently dropped; proto comment and docs do not say so | Done (merge 2fad124) | stream L | |
|
||||
| R21-L2 | Low | Contracts | `RETRIGGER` and the `WorkerFaultCategory` values `INVALID_ARGUMENTS` and `GATEWAY_AUTHENTICATION_FAILED` are reserved but not labeled so | Done (merge 2fad124) | stream L | Proto comments |
|
||||
| R21-L3 | Low | Docs | Stale notes in `stillpending.md` (sections 1.4, 3.4, 7.1) and the deferred-followups header | Done (merge 2fad124) | stream L | |
|
||||
| R21-L4 | Low | Docs | The 2026-08-19 tracker header says CLI-47..50, IPC-36/37, TST-35 are `Not started` while the rows say `Done` | Done | K | Header row corrected in this stream |
|
||||
| R21-L5 | Low | Tests | No tests for `GatewayConfigPathRules` | Done (merge 2fad124) | stream L | |
|
||||
| R21-L6 | Low | Tests | No tests for `DashboardRedactor` | Done (merge 2fad124) | stream L | |
|
||||
| R21-L7 | Low | Tests | No tests for `EventSubscriberReplayAttachment` | Done (merge 2fad124) | stream L | |
|
||||
| R21-L8 | Low | Tests | No tests for the `MxStatusDetailText` table | Deferred → #178 | — | Worker-side test; needs a windev run, not done in stream L |
|
||||
|
||||
## External / blocked
|
||||
|
||||
| ID | Sev | Area | Description | Status | Commit / stream | Reason / note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R21-X1 | Medium | Security | TLS on the shared GLAuth (cycle-3 `SEC-37`) | Blocked (external) → #180 | — | Lives in `scadaproj/infra/glauth/` (separate repo and docker host); after it lands, move the deployed service to `Production` |
|
||||
| R21-X2 | Low | CI | Gitea runner registration-token reset (cycle-3 `TST-32`) | Blocked (external) → #181 | — | Web-UI action on the Gitea admin page |
|
||||
| R21-X3 | Medium | OtOpcUa | `GalaxyDriver.ShutdownAsync` leaks sessions | Blocked (external) | — | Defect is in the OtOpcUa repo; the gateway's lease expiry and detach grace eventually reap a leaked session |
|
||||
|
||||
## Cycle-1 rows re-triaged as still open (promoted)
|
||||
|
||||
These are cycle-1 findings that no later tracker re-listed. Each was checked against the integration branch, and the cycle-1 row now points here. The `Medium` rows carry correctness, security or operability weight (`R21-O1`, `R21-O8`, `R21-O9`, `R21-O16`, `R21-O21` to `R21-O23`, `R21-O30`, `R21-O31`); the `Low` rows are hygiene to batch with the next change that touches the file.
|
||||
|
||||
| ID | Cycle-1 ID | Sev | Area | Description | Status | Next step |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R21-O1 | WRK-03 | Medium | Worker | `TryStartCommandTask` returns silently when `_acceptingCommands` is false, so a command racing shutdown gets no reply and no log (`Ipc/WorkerPipeSession.cs:1240`) | Open → #182 | Reply `WorkerUnavailable` (at minimum log `WorkerCommandResultDropped`) and add `CommandAfterShutdown_RepliesWorkerUnavailable`; verify on windev |
|
||||
| R21-O2 | WRK-08 | Low | Worker | Residual events queued after `WorkerShutdown` are discarded, and the shutdown section of `docs/MxAccessWorkerInstanceDesign.md` does not say so | Open → #183 | Document the discard as v1 behavior (docs-only) |
|
||||
| R21-O3 | WRK-10 | Low | Worker | `WorkerApplication.Run` catch blocks log the exception type but never the message | Open → #184 | Add `exception_message` through `WorkerLogRedactor` in the three catch blocks; add a `WorkerApplicationTests` assertion |
|
||||
| R21-O4 | WRK-16 | Low | Worker | Seven near-identical `CreateEnvelope` overload pairs and eight `WorkerPipeClient` constructors | Open → #185 | Refactor when `WorkerPipeSession` is next touched; existing IPC tests are the guard |
|
||||
| R21-O5 | WRK-17 | Low | Worker | Gateway death surfaces as `EndOfStream` and exits 6 (`ProtocolViolation`) instead of 5 | Open → #186 | Map `EndOfStream` to `PipeConnectionFailed`; add a `WorkerApplicationTests` case |
|
||||
| R21-O6 | WRK-18 | Low | Worker | Event-queue overflow exits as generic `UnexpectedFailure` (1) | Open → #186 | Add a dedicated exit code (stream E already uses 7 for unhandled exceptions, so use 8) and update the overflow paragraph of `docs/MxAccessWorkerInstanceDesign.md` |
|
||||
| R21-O7 | WRK-19 | Low | Worker | The documented per-command start/end log with correlation id and latency does not exist | Open → #184 | Add level-gated `WorkerCommandStarted`/`WorkerCommandCompleted` in `StaCommandDispatcher.ExecuteQueuedCommandAsync` |
|
||||
| R21-O8 | GWC-05 | Medium | Gateway | Worker pipe is created with no ACL and no `CurrentUserOnly` (`SessionWorkerClientFactory.CreatePipe`); only the unguessable name and `maxNumberOfServerInstances: 1` limit access | Open → #187 | Add `PipeOptions.CurrentUserOnly` (or a `PipeSecurity` for the worker account); confirm the worker runs as the same user on windev; add a launch test |
|
||||
| R21-O9 | GWC-09 | Medium | Gateway | `WorkerProcessStartedProbe` only checks `HasExited`, so `StartupProbeRetryAttempts` and `StartupProbeRetryDelayMilliseconds` can never retry anything | Open → #188 | Either make the probe wait for a real readiness signal, or remove the two options and their docs so config does not promise a retry that cannot happen |
|
||||
| R21-O10 | GWC-13 | Low | Gateway | Worker-ready wait is a 25 ms poll loop (`GatewaySession`, `pollIntervalMs = 25`) | Open → #189 | Replace with a `TaskCompletionSource` signalled on state change |
|
||||
| R21-O11 | GWC-17 | Low | Gateway | `SparseArrayExpander` throws `Grpc.Core.RpcException` from the Sessions layer | Open → #190 | Throw a domain exception and map it to `InvalidArgument` in the gRPC layer |
|
||||
| R21-O12 | GWC-18 | Low | Gateway | `GatewaySession` exposes `DisposeAsync` without implementing `IAsyncDisposable` | Open → #191 | Declare the interface; check `await using` call sites |
|
||||
| R21-O13 | GWC-19 | Low | Gateway | `GatewaySession.KillWorker(string)` has no production callers (only test comments mention it) | Open → #191 | Delete it and fix the comments, or document it as the synchronous kill primitive |
|
||||
| R21-O14 | GWC-20 | Low | Gateway | `HeartbeatCheckInterval` is fed from `HeartbeatIntervalSeconds`, conflating the send and check intervals | Open → #192 | Split the option or rename it, and update `docs/GatewayConfiguration.md` |
|
||||
| R21-O15 | GWC-21 | Low | Gateway | `EventChannelFullModeTimeout` and `HeartbeatStuckCeiling` are compile-time constants (`WorkerClientOptions`) | Open → #192 | Expose both under `MxGateway:Worker:*` with validation, or record them as intentionally fixed |
|
||||
| R21-O16 | SEC-13 | Medium | Security | `GatewayLogRedactor.SensitiveCommandMethods` lacks `WriteSecuredBulk` and `WriteSecured2Bulk`. Latent in cycle 1, but `GatewayLogRedactorSeam` now calls `RedactCommandValue`, so a secured-bulk payload would be logged unredacted if value logging is enabled | Open → #193 | Add both bulk kinds plus a redactor test; cross-check every command kind that carries a password |
|
||||
| R21-O17 | SEC-16 | Low | Security | `apikey --pepper` is accepted on the command line (`ApiKeyAdminCommandLineParser`) and lands in shell history and the process list | Open → #194 | Prefer an env var or stdin prompt; keep the flag only with a warning |
|
||||
| R21-O18 | SEC-18 | Low | Security | `DashboardApiKeyManagementService` detects a missing pepper by matching `"pepper unavailable"` in an exception message | Open → #195 | Catch a typed exception from `ZB.MOM.WW.Auth.ApiKeys`, or check the pepper before the call |
|
||||
| R21-O19 | SEC-23 | Low | Security | The options validator does not reject a `__Host-` `CookieName` combined with `RequireHttpsCookie=false`, a cookie browsers silently drop | Open → #196 | Add the cross-field check to `ValidateDashboard` |
|
||||
| R21-O20 | SEC-24 | Low | Security | `EffectiveDashboardConfiguration` omits `DisableLogin`, `AutoLoginUser`, `RequireHttpsCookie` and `CookieName`, so the Settings page cannot show that login is disabled | Open → #196 | Project the four fields in `GatewayConfigurationProvider` |
|
||||
| R21-O21 | CLI-07 | Medium | .NET client | `ExecuteSafeUnaryAsync` wraps the retry pipeline in `CancelAfter(DefaultCallTimeout)` while each attempt has the same deadline, so a `DeadlineExceeded` first attempt consumes the whole budget and retries never run | Open → #197 | Make the outer budget `MaxAttempts x DefaultCallTimeout` (or drop the outer cap); add a wire test with a slow first attempt |
|
||||
| R21-O22 | CLI-09 | Medium | Go client | Go wraps every RPC failure in `GatewayError`; there is no typed `Unauthenticated` vs `PermissionDenied` mapping, though `ClientLibrariesDesign.md` requires one | Open → #198 | Add sentinel or typed errors usable with `errors.Is`; keep credential redaction |
|
||||
| R21-O23 | CLI-11 | Medium | Go client | `mxgw-go` has no flag for strict TLS validation, so `--tls` without a CA is always skip-verify | Open → #199 | Add `--require-certificate-validation` wired to `Options.RequireCertificateValidation` |
|
||||
| R21-O24 | CLI-19 | Low | .NET client | `InternalsVisibleTo` is declared in both `Properties/AssemblyInfo.cs` and the csproj | Open → #200 | Delete one (cosmetic) |
|
||||
| R21-O25 | CLI-22 | Low | Go client | `newCorrelationID` returns an empty string if `crypto/rand` fails | Open → #201 | Fall back to a counter-based id |
|
||||
| R21-O26 | CLI-27 | Low | Python client | `Session.close()` has no lock around `_closed`, and a repeat close returns a synthesized reply | Open → #202 | Guard with an `asyncio.Lock` and cache the real reply, as .NET and Go do |
|
||||
| R21-O27 | CLI-31 | Low | Rust client | The Rust CLI `main.rs` is now 3,023 lines | Open → #203 | Split by command group when next touched |
|
||||
| R21-O28 | CLI-33 | Low | Clients | Slow-consumer behavior is documented in the Go README but not in the Java README or the cross-language `docs/ClientBehaviorFixtures.md` | Open → #204 | Add one comparison table now that this cycle changed the Go and Java buffers |
|
||||
| R21-O29 | TST-07 | Low | Tests | Real-clock sleeps with negative assertions remain (for example `WorkerClientTests.cs:975`, 150 ms) | Open → #205 | Convert to a fake time provider where one exists; otherwise leave |
|
||||
| R21-O30 | TST-09 | Medium | Ops | Health checks cover the auth store and sessions only; Galaxy SQL, LDAP and worker-executable presence are unchecked (`GatewayApplication.cs:118`) | Open → #206 | Add a Ready-tag worker-exe presence check first, then a bounded Galaxy SQL ping; zero sessions must stay Healthy because the Overview dashboard polls the Active tier |
|
||||
| R21-O31 | TST-10 | Medium | Ops | No deploy or upgrade script or runbook for the NSSM-wrapped service | Open → #207 | Write a deployment doc from the windev/wonder deploy notes; respect the current ban on remote execution on the wonder boxes |
|
||||
| R21-O32 | TST-18 | Low | Tests | `SessionLeaseMonitorHostedService` and `OrphanWorkerCleanupHostedService` timer/startup wiring has no direct tests | Open → #205 | Add timer and startup tests with a fake time provider |
|
||||
| R21-O33 | TST-21 | Low | Ops | Serilog file sink rolls daily with no `retainedFileCountLimit` or size cap | Open → #208 | Set `retainedFileCountLimit` and `fileSizeLimitBytes` in `appsettings.json`; document |
|
||||
| R21-O34 | TST-22 | Low | Docs | The Configuration Shape JSON block in `docs/GatewayConfiguration.md` omits `DisableLogin`, `AutoLoginUser`, `CookieName`, `Tls` and `Alarms:Fallback` | Open → #209 | Add the keys to the block (docs-only) |
|
||||
|
||||
## Cycle-1 triage summary
|
||||
|
||||
The 68 cycle-1 rows that were `Not started` with no later re-listing resolved as follows.
|
||||
|
||||
| Verdict | Count | Rows |
|
||||
|---|:-:|---|
|
||||
| Done | 21 | WRK-02, WRK-05, WRK-09, WRK-13, WRK-20, GWC-10, GWC-12, GWC-22, IPC-10, IPC-12, SEC-14, SEC-17, SEC-19, SEC-21, SEC-26, SEC-27, CLI-05, CLI-06, CLI-10, CLI-13, TST-14 |
|
||||
| Won't fix | 13 | GWC-11, IPC-11, SEC-15, CLI-14, CLI-17, CLI-20, CLI-23, CLI-25, CLI-28, CLI-32, TST-19, TST-20, WRK-14 |
|
||||
| Open (promoted) | 34 | `R21-O1` through `R21-O34` above |
|
||||
|
||||
`WRK-02`, `WRK-05` and `WRK-09` are closed by stream E and become plain `Done` when `fix/review-2026-10-21-e` merges.
|
||||
|
||||
The seven rows singled out for assessment: `WRK-03` Open (`R21-O1`: the worker still returns silently from `TryStartCommandTask` after shutdown starts); `WRK-05` Done (stream E); `CLI-06` Done (stream F); `CLI-07` Open (`R21-O21`: the outer `CancelAfter` still equals the per-attempt deadline); `CLI-09` Open (`R21-O22`: no typed auth errors in Go); `CLI-11` Open (`R21-O23`: no strict-TLS flag on `mxgw-go`); `CLI-13` Done (stream G).
|
||||
|
||||
## Change log
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-10-22 | Initial tracker from the 2026-10-21 review outcomes. Stream E is recorded as done but unmerged; stream L items are in progress. 68 cycle-1 rows re-triaged and the 34 still-open ones promoted. |
|
||||
|
||||
## Final integration review (stream M)
|
||||
|
||||
| ID | Severity | Area | Finding | Status | Commit | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R21-M1 | Medium | Sessions | A worker that dies right after its handshake left the session `Ready` with a dead worker until the lease expired | Done | M: `a178d68` | Attach refuses a terminal worker; the pump observes terminal state on its first read |
|
||||
| R21-M2 | Low | Metrics | One worker fault was counted 1+K times (K = number of `StreamEvents` subscribers) | Done | M: `a0b5e91` | |
|
||||
| R21-M3 | Low | Sessions | A cancelled open, or one drained at shutdown, was reported as `OpenFailed` | Done | M: `a178d68` | Now `Cancelled` and `GatewayStopping` respectively |
|
||||
| R21-M4 | Low | Docs | Unattached-grace exemptions, subscriber-limit status code, stale event-stream description in Grpc.md | Done | M: `3ffba83` | |
|
||||
| R21-M5 | Low | Docs | Client docs described the ReadBulk timeout as per-tag | Done | M: `dd62335` | Worker-side comments (`MxAccessCommandExecutor.cs:15`, `MxAccessValueCache.cs:219`, `MxAccessValueCacheTests.cs:246`) still say per-tag: Open → #179 |
|
||||
@@ -1,5 +1,10 @@
|
||||
# MxAccessGateway — Remediation Tracking
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on gitea.dohertylan.com/dohertj2/mxaccessgw
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status here; file new work as an issue.
|
||||
>
|
||||
> Rows still open in this cycle's tracker were re-triaged and carried into the [2026-10-21 tracker](../2026-10-21/remediation/00-tracking.md) (`R21-*` rows; cycle-1 rows promoted as `R21-O1`..`R21-O34`), whose `→ #N` pointers name the Gitea issues.
|
||||
|
||||
Master progress tracker for the 2026-07-08 architecture review. Generated 2026-07-09.
|
||||
|
||||
Source review: [`../00-overall.md`](../00-overall.md) · Per-domain remediation designs are linked below and hold the full **Finding / Impact / Design / Implementation / Verification** for every entry here.
|
||||
@@ -11,7 +16,7 @@ Source review: [`../00-overall.md`](../00-overall.md) · Per-domain remediation
|
||||
- Do the work in **roadmap-tier order** (P0 → P1 → P2), respecting the `Dep` (depends-on) column within a tier.
|
||||
- When a fix lands: flip Status to `Done`, and per the repo rule, update the affected docs in the same commit.
|
||||
|
||||
**Status legend:** `Not started` · `In progress` · `In review` · `Done` · `Won't fix` (record why in the domain doc) · `N/A` (informational / positive observation, no action).
|
||||
**Status legend:** `Not started` · `In progress` · `In review` · `Done` · `Won't fix` (record why in the domain doc) · `N/A` (informational / positive observation, no action) · `Open` (re-triaged 2026-10-21: still present, promoted to the [2026-10-21 tracker](../2026-10-21/remediation/00-tracking.md)).
|
||||
|
||||
## Severity roll-up
|
||||
|
||||
@@ -63,24 +68,24 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| GWC-02 | High | P0 | M | — | Done | Faulted sessions are never swept |
|
||||
| GWC-03 | High | P0 | S | — | Done | Documented sparse-array max-length bound is unimplemented |
|
||||
| GWC-04 | Medium | P1 | M | — | Done | Full event channel stalls the worker read loop behind command replies |
|
||||
| GWC-05 | Medium | — | S | — | Not started | Worker pipe created with no ACL / no CurrentUserOnly |
|
||||
| GWC-05 | Medium | — | S | — | Open | Worker pipe created with no ACL / no CurrentUserOnly — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O8 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| GWC-06 | Medium | P2 | S | GWC-07,08 | Done | Stopwatch allocated per streamed event |
|
||||
| GWC-07 | Medium | P2 | S | GWC-06,08 | Done | Every mapped event is deep-cloned |
|
||||
| GWC-08 | Medium | P2 | M | GWC-06,07 | Done | Pipe framing allocates per frame and writes twice |
|
||||
| GWC-09 | Medium | — | M | — | Not started | Startup probe is a no-op; its retry pipeline can never retry |
|
||||
| GWC-10 | Medium | — | S | — | Not started | Envelope `sequence` monotonicity specified but not enforced |
|
||||
| GWC-11 | Low | — | S | — | Not started | `_workerClient` written under lock but read lock-free |
|
||||
| GWC-12 | Low | — | S | — | Not started | `WorkerClient.DisposeAsync` not safe against double-dispose |
|
||||
| GWC-13 | Low | — | M | — | Not started | Worker-ready wait is a 25 ms poll loop |
|
||||
| GWC-09 | Medium | — | M | — | Open | Startup probe is a no-op; its retry pipeline can never retry — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O9 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| GWC-10 | Medium | — | S | — | Done | Envelope `sequence` monotonicity specified but not enforced — **2026-10-21 triage:** resolved by spec change, not enforcement: `gateway.md:440` states `sequence` is a diagnostic aid, stamped in each side's single write path and not validated on receive |
|
||||
| GWC-11 | Low | — | S | — | Won't fix | `_workerClient` written under lock but read lock-free — **2026-10-21 triage:** reference reads are atomic and every ready/state decision reads `_workerClient` under `_syncRoot` (`EvaluateReadyUnderLock`); only diagnostic properties read it lock-free |
|
||||
| GWC-12 | Low | — | S | — | Done | `WorkerClient.DisposeAsync` not safe against double-dispose — **2026-10-21 triage:** closed by cycle-3 GWC-32 (`DisposeAsync` claims `_disposed` under `_syncRoot`; see the 2026-08-19 tracker) |
|
||||
| GWC-13 | Low | — | M | — | Open | Worker-ready wait is a 25 ms poll loop — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O10 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| GWC-14 | Low | P2 | M | — | Done | Replay ring is a `LinkedList` with a node alloc per event |
|
||||
| GWC-15 | Low | P2 | S | — | Done | Per-event gauge reads `ChannelReader.Count` every event |
|
||||
| GWC-16 | Low | — | S | — | Not started | `Invoke` resolves the session twice per command |
|
||||
| GWC-17 | Low | — | M | — | Not started | Sessions layer throws `Grpc.Core.RpcException` (layering leak) |
|
||||
| GWC-18 | Low | — | S | — | Not started | `GatewaySession` implements `DisposeAsync` without `IAsyncDisposable` |
|
||||
| GWC-19 | Low | — | S | — | Not started | Dead `GatewaySession.KillWorker(string)` + stale doc |
|
||||
| GWC-20 | Low | — | S | — | Not started | Heartbeat config semantics conflated (send vs check interval) |
|
||||
| GWC-21 | Low | — | S | GWC-04 | Not started | `EventChannelFullModeTimeout` / `HeartbeatStuckCeiling` not configurable |
|
||||
| GWC-22 | Low | — | S | — | Not started | `StreamDisconnected` always labeled `"Detached"` |
|
||||
| GWC-16 | Low | — | S | — | Won't fix | `Invoke` resolves the session twice per command — measured negligible, deliberately not collapsed (see domain doc) |
|
||||
| GWC-17 | Low | — | M | — | Open | Sessions layer throws `Grpc.Core.RpcException` (layering leak) — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O11 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| GWC-18 | Low | — | S | — | Open | `GatewaySession` implements `DisposeAsync` without `IAsyncDisposable` — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O12 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| GWC-19 | Low | — | S | — | Open | Dead `GatewaySession.KillWorker(string)` + stale doc — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O13 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| GWC-20 | Low | — | S | — | Open | Heartbeat config semantics conflated (send vs check interval) — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O14 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| GWC-21 | Low | — | S | GWC-04 | Open | `EventChannelFullModeTimeout` / `HeartbeatStuckCeiling` not configurable — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O15 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| GWC-22 | Low | — | S | — | Done | `StreamDisconnected` always labeled `"Detached"` — **2026-10-21 triage:** closed by the 2026-10-21 review (R21-G7), `6ae5ed9`: `StreamDisconnected` records the real reason |
|
||||
| GWC-23 | Info | — | — | — | N/A | `MaxEventSubscribersPerSession` dead in default single-subscriber mode |
|
||||
|
||||
### Worker — [20-worker.md](20-worker.md)
|
||||
@@ -88,25 +93,25 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| ID | Sev | Tier | Eff | Dep | Status | Title |
|
||||
|---|---|:-:|:-:|---|---|---|
|
||||
| WRK-01 | High | P0 | M | — | Done | Long `ReadBulk` self-faults as `StaHung`; all replies then dropped |
|
||||
| WRK-02 | Medium | — | M | — | Not started | STA thread death after startup is silent; future work hangs forever |
|
||||
| WRK-03 | Medium | — | S | WRK-02 | Not started | Commands after shutdown starts are dropped with no reply |
|
||||
| WRK-02 | Medium | — | M | — | Done | STA thread death after startup is silent; future work hangs forever — **2026-10-21 triage:** closed by the 2026-10-21 review (R21-K6): an STA loop fault raises a `StaHung` WorkerFault; stream E `7618caf`, merged |
|
||||
| WRK-03 | Medium | — | S | WRK-02 | Open | Commands after shutdown starts are dropped with no reply — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O1 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| WRK-04 | Medium | — | M | — | Done | Envelope `sequence` can appear out of order on the wire |
|
||||
| WRK-05 | Medium | — | M | — | Not started | One transient alarm-poll failure kills the whole session |
|
||||
| WRK-05 | Medium | — | M | — | Done | One transient alarm-poll failure kills the whole session — **2026-10-21 triage:** closed by the 2026-10-21 review (R21-K3): consecutive alarm-poll failure threshold of 3 in `MxAccessStaSession`; stream E `7618caf`, merged |
|
||||
| WRK-06 | Medium | P2 | S | — | Done | `MXSTATUS_PROXY` conversion reflects per field, per event |
|
||||
| WRK-07 | Medium | P1 | M | WRK-04 | Done | Documented outbound write priority not implemented |
|
||||
| WRK-08 | Low | — | S | — | Not started | Residual event queue discarded at graceful shutdown, undocumented |
|
||||
| WRK-09 | Low | — | S | — | Not started | No `AppDomain.UnhandledException` hook |
|
||||
| WRK-10 | Low | — | S | — | Not started | Top-level catch logs exception type but never message |
|
||||
| WRK-08 | Low | — | S | — | Open | Residual event queue discarded at graceful shutdown, undocumented — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O2 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| WRK-09 | Low | — | S | — | Done | No `AppDomain.UnhandledException` hook — **2026-10-21 triage:** closed by the 2026-10-21 review (R21-K7): `AppDomain.UnhandledException` hook, exit code 7; stream E `7618caf`, merged |
|
||||
| WRK-10 | Low | — | S | — | Open | Top-level catch logs exception type but never message — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O3 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| WRK-11 | Low | P2 | S | — | Done | Every accepted event is defensively cloned on enqueue |
|
||||
| WRK-12 | Low | P2 | M | WRK-04 | Done | No event batching per envelope; one flush per event; 25 ms poll |
|
||||
| WRK-13 | Low | — | S | — | Not started | Frame writer allocates a fresh buffer per frame; reader pools |
|
||||
| WRK-14 | Low | — | M | — | Not started | Private-field naming split `_camelCase` vs `camelCase` |
|
||||
| WRK-13 | Low | — | S | — | Done | Frame writer allocates a fresh buffer per frame; reader pools — **2026-10-21 triage:** worker `WorkerFrameWriter` rents the frame from `ArrayPool` (`Ipc/WorkerFrameWriter.cs:675`); the reader was already pooled |
|
||||
| WRK-14 | Low | — | M | — | Won't fix | Private-field naming split `_camelCase` vs `camelCase` — **2026-10-21 triage:** cosmetic; the style guide permits both `_camelCase` and bare `camelCase` where established, and a mass rename is churn without behavior change |
|
||||
| WRK-15 | Low | P2 | S | — | Done | Doc drift: STA thread name and heartbeat-counter note |
|
||||
| WRK-16 | Low | — | S | — | Not started | Boilerplate duplication in IPC envelope/ctor overloads |
|
||||
| WRK-17 | Low | — | S | — | Not started | Gateway death exits with wrong exit code (6 not 5) |
|
||||
| WRK-18 | Low | — | S | — | Not started | Event-queue overflow exits as generic `UnexpectedFailure` |
|
||||
| WRK-19 | Low | — | S | — | Not started | Command start/end logging with correlation id absent |
|
||||
| WRK-20 | Low | — | M | WRK-01..04 | Not started | Test-coverage gaps for the failure modes above |
|
||||
| WRK-16 | Low | — | S | — | Open | Boilerplate duplication in IPC envelope/ctor overloads — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O4 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| WRK-17 | Low | — | S | — | Open | Gateway death exits with wrong exit code (6 not 5) — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O5 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| WRK-18 | Low | — | S | — | Open | Event-queue overflow exits as generic `UnexpectedFailure` — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O6 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| WRK-19 | Low | — | S | — | Open | Command start/end logging with correlation id absent — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O7 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| WRK-20 | Low | — | M | WRK-01..04 | Done | Test-coverage gaps for the failure modes above — **2026-10-21 triage:** STA-death, sequence-monotonic, long-ReadBulk and late-reply tests exist; the one residual (command-after-shutdown reply) is carried by WRK-03 as R21-O1 |
|
||||
|
||||
### Contracts & IPC — [30-contracts-ipc.md](30-contracts-ipc.md)
|
||||
|
||||
@@ -119,11 +124,11 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| IPC-05 | Medium | P2 | S | — | Done | Redundant deep copies on command and event hot paths |
|
||||
| IPC-06 | Medium | P2 | S | — | Done | `gateway.md` Worker Envelope sketch no longer matches the contract |
|
||||
| IPC-07 | Medium | P2 | S | — | Done | `docs/Grpc.md` says six RPCs; there are seven |
|
||||
| IPC-08 | Medium | — | M | — | Not started | `WorkerCancel` defined and handled but never sent |
|
||||
| IPC-08 | Medium | — | M | — | Done | `WorkerCancel` defined and handled but never sent — resolved by `7c1ea12`/`a1a38b5`; residual (caller-cancellation leg) tracked as cycle-3 IPC-34 |
|
||||
| IPC-09 | Medium | P1 | M | IPC-01 | Done | Known codegen fragilities have no in-repo guards |
|
||||
| IPC-10 | Low | — | S | — | Not started | Envelope `sequence` is write-only; monotonicity never validated |
|
||||
| IPC-11 | Low | — | S | — | Not started | No protocol version negotiation despite `supported_protocol_version` name |
|
||||
| IPC-12 | Low | — | S | — | Not started | Gateway frame writer has no write lock; integrity rests on undocumented invariant |
|
||||
| IPC-10 | Low | — | S | — | Done | Envelope `sequence` is write-only; monotonicity never validated — **2026-10-21 triage:** same resolution as GWC-10: `gateway.md:440` documents `sequence` as a diagnostic aid |
|
||||
| IPC-11 | Low | — | S | — | Won't fix | No protocol version negotiation despite `supported_protocol_version` name — **2026-10-21 triage:** the review itself recommended no change; exact-match protocol version is correct for a lockstep-deployed pair, and the next bump should introduce a min/max range |
|
||||
| IPC-12 | Low | — | S | — | Done | Gateway frame writer has no write lock; integrity rests on undocumented invariant — **2026-10-21 triage:** IPC-13 collapsed the gateway write to one pooled-buffer write, and `WorkerClient.WriteLoopAsync` is its only caller |
|
||||
| IPC-13 | Low | P2 | S | IPC-05 | Done | Gateway writer serializes twice and issues two stream writes |
|
||||
| IPC-14 | Low | P2 | S | IPC-05 | Done | Gateway reader allocates fresh array per frame; worker rents from `ArrayPool` |
|
||||
| IPC-15 | Low | P2 | M | — | Done | Worker event delivery 25 ms poll with per-event write+flush, no batching |
|
||||
@@ -151,21 +156,21 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| SEC-10 | Medium | P1 | L | — | Done | No API-key expiry |
|
||||
| SEC-11 | Medium | P1 | M | SEC-08 | Done | No rate limiting or lockout on either auth surface |
|
||||
| SEC-12 | Medium | P1 | M | — | Done | Dashboard Close/Kill bypass the canonical audit store |
|
||||
| SEC-13 | Low | — | S | SEC-30 | Not started | Redactor's credential-command list omits secured-bulk variants |
|
||||
| SEC-14 | Low | — | S | SEC-02,20 | Not started | `/metrics` + `/health` unauthenticated; a metric leaks session ids |
|
||||
| SEC-15 | Low | — | S | — | Not started | GET `/logout` skips antiforgery |
|
||||
| SEC-16 | Low | — | S | — | Not started | CLI accepts the pepper as a command-line argument |
|
||||
| SEC-17 | Low | — | S | — | Not started | Interceptor does not override client-streaming/duplex handlers |
|
||||
| SEC-18 | Low | — | S | — | Not started | Pepper-unavailable detection matches library message text |
|
||||
| SEC-19 | Low | — | S | — | Not started | Canonical audit store re-issues `CREATE TABLE` per write/read |
|
||||
| SEC-13 | Low | — | S | SEC-30 | Open | Redactor's credential-command list omits secured-bulk variants — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O16 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| SEC-14 | Low | — | S | SEC-02,20 | Done | `/metrics` + `/health` unauthenticated; a metric leaks session ids — **2026-10-21 triage:** closed by the 2026-10-21 review (R21-G11), `65bca80` + `82a856b`: opt-in `Metrics:RequireAuthentication`; `/health` stays open by design |
|
||||
| SEC-15 | Low | — | S | — | Won't fix | GET `/logout` skips antiforgery — **2026-10-21 triage:** accepted nuisance: GET `/logout` skips antiforgery deliberately (comment in `DashboardEndpointRouteBuilderExtensions.cs`); the POST path validates antiforgery |
|
||||
| SEC-16 | Low | — | S | — | Open | CLI accepts the pepper as a command-line argument — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O17 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| SEC-17 | Low | — | S | — | Done | Interceptor does not override client-streaming/duplex handlers — **2026-10-21 triage:** closed by the 2026-10-21 review (R21-G2), `19cc74f`: client- and duplex-streaming calls authenticate and fail closed to admin |
|
||||
| SEC-18 | Low | — | S | — | Open | Pepper-unavailable detection matches library message text — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O18 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| SEC-19 | Low | — | S | — | Done | Canonical audit store re-issues `CREATE TABLE` per write/read — **2026-10-21 triage:** `SqliteCanonicalAuditStore` latches schema creation, so `CREATE TABLE IF NOT EXISTS` runs once (a failure leaves the latch open) |
|
||||
| SEC-20 | Low | P1 | S | — | Done | `session_id` metric tag is unbounded cardinality |
|
||||
| SEC-21 | Low | — | M | — | Not started | Snapshot publisher works every second regardless of audience |
|
||||
| SEC-21 | Low | — | M | — | Done | Snapshot publisher works every second regardless of audience — **2026-10-21 triage:** `DashboardSnapshotFeed` is idle-gated (no viewers, no timer), API-key summaries refresh every 15 s, and configuration is sent once per connection (R21-D4, `6733519`) |
|
||||
| SEC-22 | Low | P2 | M | — | Done | `docs/Authentication.md` documents types no longer in this repo |
|
||||
| SEC-23 | Low | — | S | SEC-03,04 | Not started | Validator ignores `DisableLogin`/`AutoLoginUser`/`RequireHttpsCookie`/`CookieName` |
|
||||
| SEC-24 | Low | — | S | SEC-04 | Not started | Effective-config view omits the riskiest dashboard flags |
|
||||
| SEC-23 | Low | — | S | SEC-03,04 | Open | Validator ignores `DisableLogin`/`AutoLoginUser`/`RequireHttpsCookie`/`CookieName` — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O19 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| SEC-24 | Low | — | S | SEC-04 | Open | Effective-config view omits the riskiest dashboard flags — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O20 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| SEC-25 | Low | P2 | M | SEC-02 | Done | Per-session EventsHub ACL is an acknowledged TODO |
|
||||
| SEC-26 | Low | — | M | — | Not started | Audit trail has no retention or pruning |
|
||||
| SEC-27 | Low | — | S | — | Not started | Dashboard `GatewayStatus` is hardcoded Healthy |
|
||||
| SEC-26 | Low | — | M | — | Done | Audit trail has no retention or pruning — **2026-10-21 triage:** `Security/Audit/AuditDrainService.cs` prunes by `AuditRetentionDays` (validated in `GatewayOptionsValidator`) on an hourly sweep |
|
||||
| SEC-27 | Low | — | S | — | Done | Dashboard `GatewayStatus` is hardcoded Healthy — **2026-10-21 triage:** closed by the 2026-10-21 review (R21-D9), `6733519`: `GatewayStatus` derives from health checks |
|
||||
| SEC-28 | Info | — | S | — | N/A | Positive security observations (preserve under change) |
|
||||
| SEC-29 | Info | — | S | — | N/A | UI-stack rule verified compliant |
|
||||
| SEC-30 | Info | P2 | S | SEC-13 | Done | Value-logging feature is unwired end-to-end |
|
||||
@@ -178,35 +183,35 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| CLI-02 | High | P1 | M | — | Done | Rust crate unbuildable outside the repo (`build.rs` path + `--no-verify`) |
|
||||
| CLI-03 | High | P0 | M | — | Done | Rust `invoke` never validates HRESULT / MXSTATUS_PROXY |
|
||||
| CLI-04 | High | P2 | L | CLI-15 | Done | Typed-command parity gap across all five clients |
|
||||
| CLI-05 | Medium | — | S | — | Not started | .NET session cannot be re-attached to an existing session id |
|
||||
| CLI-06 | Medium | — | S | — | Not started | .NET `DisposeAsync` blocks/throws on unreachable gateway |
|
||||
| CLI-07 | Medium | — | S | — | Not started | .NET retry budget self-defeats on `DeadlineExceeded` |
|
||||
| CLI-05 | Medium | — | S | — | Done | .NET session cannot be re-attached to an existing session id — **2026-10-21 triage:** closed by the 2026-10-21 review (R21-CN5), `6f0b408`: `MxGatewayClient.AttachSession` |
|
||||
| CLI-06 | Medium | — | S | — | Done | .NET `DisposeAsync` blocks/throws on unreachable gateway — **2026-10-21 triage:** closed by the 2026-10-21 review (R21-CN3), `6f0b408`: `MxGatewaySession.DisposeAsync` is best-effort and never throws |
|
||||
| CLI-07 | Medium | — | S | — | Open | .NET retry budget self-defeats on `DeadlineExceeded` — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O21 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| CLI-08 | Medium | — | S | CLI-03 | Done | .NET/Go/Java treat any nonzero HRESULT as failure (should be `< 0`) — landed via 2026-07-12 [CLI-38](../2026-07-12/remediation/50-clients.md#cli-38--align-netgojava-on-hresult--0-lands-prior-cli-08-cures-the-doc-drift---medium--p1) |
|
||||
| CLI-09 | Medium | — | M | — | Not started | Go has no typed auth-error mapping (Unauthenticated vs PermissionDenied) |
|
||||
| CLI-10 | Medium | — | M | — | Not started | Go uses deprecated `grpc.DialContext` + `grpc.WithBlock()` |
|
||||
| CLI-11 | Medium | — | S | — | Not started | Go CLI cannot opt into strict TLS validation |
|
||||
| CLI-09 | Medium | — | M | — | Open | Go has no typed auth-error mapping (Unauthenticated vs PermissionDenied) — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O22 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| CLI-10 | Medium | — | M | — | Done | Go uses deprecated `grpc.DialContext` + `grpc.WithBlock()` — **2026-10-21 triage:** closed by the 2026-10-21 review (R21-CG2), `a3a7bd2`: `grpc.NewClient` replaces `DialContext` + `WithBlock` |
|
||||
| CLI-11 | Medium | — | S | — | Open | Go CLI cannot opt into strict TLS validation — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O23 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| CLI-12 | Medium | P2 | S | — | Done | Java docs still say "Java 21" after the JDK 17 retarget |
|
||||
| CLI-13 | Medium | — | M | — | Not started | Java event-stream buffer hardcoded 16, cancel-on-overflow |
|
||||
| CLI-14 | Medium | — | S | — | Not started | Python default TLS is blocking TOFU pin with silent `localhost` SNI |
|
||||
| CLI-13 | Medium | — | M | — | Done | Java event-stream buffer hardcoded 16, cancel-on-overflow — **2026-10-21 triage:** closed by the 2026-10-21 review (R21-CJ1/CJ2), `48c9323`: event queue is configurable (default 1024) and overflow keeps buffered events |
|
||||
| CLI-14 | Medium | — | S | — | Won't fix | Python default TLS is blocking TOFU pin with silent `localhost` SNI — **2026-10-21 triage:** documented and intentional: the Python README describes the trust-on-first-use default and `--require-certificate-validation`; the `localhost` SNI default matches the gateway's self-signed SAN (`options.py:160`) |
|
||||
| CLI-15 | Medium | P2 | M | — | Done | No client handles `ReplayGap` or offers a reconnect helper |
|
||||
| CLI-16 | Medium | P2 | S | CLI-12 | Done | `docs/ClientPackaging.md` drifted from Python naming and .NET `.slnx` |
|
||||
| CLI-17 | Medium | — | M | CLI-14 | Not started | TLS default posture inconsistent across the five clients |
|
||||
| CLI-17 | Medium | — | M | CLI-14 | Won't fix | TLS default posture inconsistent across the five clients — **2026-10-21 triage:** documented divergence (`docs/CrossLanguageSmokeMatrix.md`); a strict default would break every deployment on the gateway's self-signed certificate. Strict opt-in exists per client; the Go CLI gap is R21-O23 |
|
||||
| CLI-18 | Low | P2 | S | — | Done | .NET csproj records no `<Version>` |
|
||||
| CLI-19 | Low | — | S | — | Not started | .NET duplicate `InternalsVisibleTo` (AssemblyInfo + csproj) |
|
||||
| CLI-20 | Low | — | S | CLI-17 | Not started | .NET `--tls` without CA installs accept-all callback |
|
||||
| CLI-19 | Low | — | S | — | Open | .NET duplicate `InternalsVisibleTo` (AssemblyInfo + csproj) — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O24 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| CLI-20 | Low | — | S | CLI-17 | Won't fix | .NET `--tls` without CA installs accept-all callback — **2026-10-21 triage:** same decision as CLI-17: documented, intentional default; `RequireCertificateValidation` opts in |
|
||||
| CLI-21 | Low | P2 | S | — | Done | Go `ClientVersion = "0.1.0-dev"` stale vs tagged releases |
|
||||
| CLI-22 | Low | — | S | — | Not started | Go `newCorrelationID` swallows `crypto/rand` error → empty id |
|
||||
| CLI-23 | Low | — | S | — | Not started | Go nil-vs-empty bulk short-circuit asymmetry |
|
||||
| CLI-22 | Low | — | S | — | Open | Go `newCorrelationID` swallows `crypto/rand` error → empty id — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O25 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| CLI-23 | Low | — | S | — | Won't fix | Go nil-vs-empty bulk short-circuit asymmetry — **2026-10-21 triage:** cosmetic; an empty bulk is a local no-op in some helpers and a wire call in others |
|
||||
| CLI-24 | Low | — | S | — | Done | Java `MxEventStream` single-consumer constraint undocumented (closed 2026-08-07 per 2026-07-12 review old-tracker action; documented at MxEventStream.java:25 "Single consumer") |
|
||||
| CLI-25 | Low | — | S | — | Not started | Java `close()` does not await channel termination |
|
||||
| CLI-25 | Low | — | S | — | Won't fix | Java `close()` does not await channel termination — **2026-10-21 triage:** `closeAndAwaitTermination()` is the documented awaiting path; the leak window is bounded by JVM exit |
|
||||
| CLI-26 | Low | P2 | S | — | Done | Python `version.py` (0.1.0) ≠ `pyproject.toml` (0.1.2) |
|
||||
| CLI-27 | Low | — | S | — | Not started | Python `Session.close()` not concurrency-safe; synthesizes reply |
|
||||
| CLI-28 | Low | — | S | — | Not started | Python circular-import workaround at end of `session.py` |
|
||||
| CLI-27 | Low | — | S | — | Open | Python `Session.close()` not concurrency-safe; synthesizes reply — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O26 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| CLI-28 | Low | — | S | — | Won't fix | Python circular-import workaround at end of `session.py` — **2026-10-21 triage:** the import cycle is deliberate, marked `# noqa: E402`, and covered by the Python test suite |
|
||||
| CLI-29 | Low | P2 | S | — | Done | Rust `CLIENT_VERSION` (0.1.0-dev) ≠ `Cargo.toml` (0.1.2) |
|
||||
| CLI-30 | Low | — | S | CLI-04 | Done | Rust has no `unregister` typed helper |
|
||||
| CLI-31 | Low | — | M | — | Not started | Rust CLI is a single 2,699-line `main.rs` |
|
||||
| CLI-32 | Low | — | S | — | Not started | Client-side bulk caps differ (.NET/Java unbounded) |
|
||||
| CLI-33 | Low | — | S | CLI-01,13 | Not started | Per-language event backpressure semantics undocumented |
|
||||
| CLI-31 | Low | — | M | — | Open | Rust CLI is a single 2,699-line `main.rs` — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O27 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| CLI-32 | Low | — | S | — | Won't fix | Client-side bulk caps differ (.NET/Java unbounded) — **2026-10-21 triage:** the gateway is the authority for bulk caps; the divergence affects only the error type for an oversized call |
|
||||
| CLI-33 | Low | — | S | CLI-01,13 | Open | Per-language event backpressure semantics undocumented — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O28 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| CLI-34 | Low | — | S | — | Done | Python `build/`/`.pytest_cache/` present on disk (untracked) (closed 2026-08-07 per 2026-07-12 review old-tracker action; both gitignored in clients/python/.gitignore) |
|
||||
|
||||
### Testing, docs & gaps — [60-testing-docs-gaps.md](60-testing-docs-gaps.md)
|
||||
@@ -218,23 +223,23 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| TST-03 | High | P1 | M | — | Done | No CI exists |
|
||||
| TST-04 | High | P2 | L | — | Done | Session-resilience epic 16/28 tasks unfinished |
|
||||
| TST-05 | Medium | P1 | S | TST-03 | Partially done | Real-worker control/COM paths verified opt-in only. **Scheduling half closed 2026-08-10** by the TST-25 `nightly-windev` job (`.gitea/workflows/ci.yml`, cron `0 6 * * *` → `scripts/ci/run-windev-ci.sh live`, which sets `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`, runs `WorkerLiveMxAccessSmokeTests`, and opens a Gitea issue on failure) — "opt-in, run by memory" is now "runs nightly, reports failures". **Residual: the coverage-audit half.** The live suite's 8 facts cover all six late-added COM commands but none of the five control commands (`Ping`, `GetSessionState`, `GetWorkerInfo`, `DrainEvents`, `ShutdownWorker`), which real workers answer in `Worker/Ipc/WorkerPipeSession.cs` yet are still only exercised through `FakeWorkerHarness` canned replies — precisely the masking the finding named |
|
||||
| TST-06 | Medium | — | M | — | Not started | Dashboard live-data path untested |
|
||||
| TST-07 | Medium | — | S | — | Not started | Real-clock sleeps with negative assertions are latent flakes |
|
||||
| TST-06 | Medium | — | M | — | Materially narrowed | Dashboard live-data path untested — `DashboardLiveDataServiceTests`, `EventsHubTests`, `EventsHubViewerRegistryTests`, `AlarmsHubPublisherTests`, `SessionDetailsPageEventAclTests` now exist (landed with SEC-25/TST-16); needs a re-audit-and-close pass, not re-implementation |
|
||||
| TST-07 | Medium | — | S | — | Open | Real-clock sleeps with negative assertions are latent flakes — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O29 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| TST-08 | Medium | P1 | M | — | Done | Full-suite orphaned testhost processes (does not reproduce; doc de-stale) |
|
||||
| TST-09 | Medium | — | M | — | Not started | Health checks cover only the auth store |
|
||||
| TST-10 | Medium | — | M | — | Not started | Deployment/upgrade undocumented and hand-rolled |
|
||||
| TST-09 | Medium | — | M | — | Open | Health checks cover only the auth store — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O30 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| TST-10 | Medium | — | M | — | Open | Deployment/upgrade undocumented and hand-rolled — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O31 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| TST-11 | Medium | P2 | M | — | Done | No version discipline on server; client versions drift |
|
||||
| TST-12 | Medium | P0 | S | — | Done | CLAUDE.md misstates default retention behaviour |
|
||||
| TST-13 | Medium | P2 | S | — | Done | gateway.md carries stale design-era sketches |
|
||||
| TST-14 | Medium | P2 | S | — | Not started | Repo-root working artifacts need triage |
|
||||
| TST-15 | Medium | P2 | M | TST-04 | Not started | Dashboard EventsHub has no per-session ACL |
|
||||
| TST-16 | Medium | — | S | — | Not started | `Dashboard:ShowTagValues` is a dead flag |
|
||||
| TST-17 | Medium | — | S | — | Not started | Vendor-gated alarm parity residuals silently lossy |
|
||||
| TST-18 | Low | — | S | — | Not started | Hosted-service wrappers untested |
|
||||
| TST-19 | Low | — | S | — | Not started | Keep FakeWorkerHarness canned replies in lockstep |
|
||||
| TST-20 | Low | — | S | — | Not started | E2E script papers over a real advise-without-consumer sharp edge |
|
||||
| TST-21 | Low | — | S | — | Not started | Log rotation configured but minimal |
|
||||
| TST-22 | Low | — | S | — | Not started | Config-shape JSON block omits documented keys |
|
||||
| TST-14 | Medium | P2 | S | — | Done | Repo-root working artifacts need triage — **2026-10-21 triage:** root `oldtasks.md` and the `*-docs-*.md` working files are no longer in the tree; `stillpending.md` remains as the backlog and stream L is refreshing it |
|
||||
| TST-15 | Medium | P2 | M | TST-04 | Done | Dashboard EventsHub has no per-session ACL |
|
||||
| TST-16 | Medium | P1 | S | — | Done | `Dashboard:ShowTagValues` is a dead flag |
|
||||
| TST-17 | Medium | — | S | — | Materially changed | Vendor-gated alarm parity residuals silently lossy — `WnWrapAlarmConsumer.cs:309-327` now passes `szDomainName`/`szOprFullName`; the vendor 8-arg leg is recorded "unavailable by configuration" with evidence (`docs/AlarmProbeFindings.md`, `d1ae43d`, `540a118`); "silent drop, unchanged" is no longer accurate — needs a worker-domain refresh pass |
|
||||
| TST-18 | Low | — | S | — | Open | Hosted-service wrappers untested — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O32 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| TST-19 | Low | — | S | — | Won't fix | Keep FakeWorkerHarness canned replies in lockstep — **2026-10-21 triage:** ongoing discipline rather than a work item; the harness shares production framing and the nightly live tier (`nightly-windev`) catches canned-reply drift |
|
||||
| TST-20 | Low | — | S | — | Won't fix | E2E script papers over a real advise-without-consumer sharp edge — **2026-10-21 triage:** documented sharp edge in `docs/GatewayTesting.md`; the e2e script's interleaved drains are the intended workaround |
|
||||
| TST-21 | Low | — | S | — | Open | Log rotation configured but minimal — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O33 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| TST-22 | Low | — | S | — | Open | Config-shape JSON block omits documented keys — **2026-10-21 triage:** still present on the integration branch; promoted to R21-O34 in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) |
|
||||
| TST-23 | Low | P2 | S | — | Done | Bidirectional `Session` RPC never built |
|
||||
| TST-24 | Low | P2 | M | TST-03 | Done | Client wire behaviour has no automated verification — closed 2026-08-10. Go/Rust/Java already had real-server wire tests (the finding's premise was stale); the genuine gaps were .NET (transport-interface fake everywhere, no server package) and Python (stub monkeypatch everywhere but one opt-in TLS test). Added `MxGatewayClientWireTests` + `WireFakeGatewayServer` (Kestrel h2c) and `tests/test_wire_fake_gateway.py` (`grpc.aio` loopback), plus a `dotnet test` step for the .NET client in the `portable` CI job. Caught a real bug: Python `connect()` built the `grpc.aio` channel inside `asyncio.to_thread` and failed for every non-stub connection |
|
||||
|
||||
@@ -253,6 +258,10 @@ Findings the review flagged as one coordinated design pass — sequence them tog
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-10-22 | **Cycle-1 `Not started` rows re-triaged against the 2026-10-21 integration branch** (stream K). All 68 rows that no later tracker had re-listed now carry a verdict: 21 `Done` (closed by earlier work or by the 2026-10-21 review), 13 `Won't fix` (reason on the row), 34 `Open` (promoted as `R21-O1`..`R21-O34` in [`../2026-10-21/remediation/00-tracking.md`](../2026-10-21/remediation/00-tracking.md) with a next step). Rows closed by stream E are marked pending merge of that stream. |
|
||||
| 2026-08-17 | **TST-16 → `Done`: `Dashboard:ShowTagValues` now gates every dashboard value surface** (branch `feat/followup-closeout`). The flag was live on one seam only (the events-hub mirror, shipped with SEC-25); the two recorded residuals are closed, so it is no longer a partly-dead flag. **Alarms hub:** `AlarmsHubPublisher` took `IOptions<GatewayOptions>` and now clears `current_value`/`limit_value` from a **deep clone** of each `AlarmFeedMessage` on both value-bearing payload arms (`transition`, `active_alarm`) before the `/hubs/alarms` broadcast. Clone-only, matching `DashboardEventBroadcaster.RedactValues`: the source message fans out from one feed to gRPC `StreamAlarms` subscribers and the alarms page, none of which this dashboard-display flag governs. The redaction switches on the value-bearing arms and passes everything else through untouched and uncloned, so a payload arm added later (the `snapshot_status` arm landing alongside this change) is forwarded unbroken rather than silently dropped. **`/browse`:** the gate lives at the service boundary per TST-16's own recommendation — `DashboardLiveDataService` substitutes `DashboardTagValue.RedactedValueText` (`[redacted]`) for the formatted value text, so the value never enters the render tree and `BrowsePage.razor` needed no change; data type, quality, source timestamp, and read errors stay real, keeping the panel diagnostic. A visible marker, not a blank, so a suppressed value is distinguishable from a tag that read back empty. Tests: new `AlarmsHubPublisherTests` (4 — both arms redacted with metadata intact, source not mutated, `true` passes values through as the same instance, valueless arms forwarded as the same instance) and two `DashboardLiveDataServiceTests` cases (`false` → `[redacted]`, `true` → formatted); `DashboardEventBroadcasterTests` re-run as regression. Docs: `docs/GatewayConfiguration.md`'s `ShowTagValues` row rewritten from "gates only the events hub mirror, two still-open residuals" to the three seams it now covers, with the "no gRPC client is affected" scope statement; `docs/GatewayDashboardDesign.md` gained the redaction note on the `AlarmsHub` row and had its Browse-panel paragraph corrected (it claimed the panel "always shows values regardless of `Dashboard:ShowTagValues`"). |
|
||||
| 2026-08-17 | **Alarm-snapshot truncation now has a structural degraded-status signal** (branch `feat/deferred-closeout`, commits `693a78d` + `b9fb0dd`). No review ID — this is branch work outside the 153-finding register, recorded here so the tracker is not silent on a shipped change to the alarm surface. Before it, a capped `GetXmlCurrentAlarms2` fetch suppressed absence-implies-Clear inference and said so only in a rate-limited worker stderr warning, so no client and no operator could tell a complete active set from a capped one. Two additive proto3 booleans carry the verdict out — `QueryActiveAlarmsReplyPayload.snapshot_truncated = 2` and `ActiveAlarmSnapshot.from_truncated_snapshot = 16` (per record, because `QueryActiveAlarms` is a bare `stream ActiveAlarmSnapshot` with no envelope; the reply payload states it too, since a prefix filter can leave zero records and a capped fetch with nothing to report still has to say so). Flow: `WnWrapAlarmConsumer` → `AlarmDispatcher` / `IAlarmCommandHandler` → `MxAccessCommandExecutor` reply → `GatewayAlarmMonitor` → `IGatewayAlarmService.SnapshotTruncated` → `AlarmsPage` banner. `b9fb0dd` then made the pairing structural after review: `IMxAccessAlarmConsumer` and `IAlarmCommandHandler` expose one accessor (`SnapshotActiveAlarms(out bool truncated)` / `QueryActive(..., out bool snapshotTruncated)`) satisfied from a single lock acquisition, so the snapshot and its verdict can no longer be read across a poll; the separate `LastSnapshotTruncated` property is gone from every layer. Detection is deliberately unchanged (`fetchedRecordCount >= maxAlarmsPerFetch`); switching to `ALARM_RECORDS/@COUNT` stays blocked on probe evidence (`docs/AlarmProbeFindings.md`). Not latched, and dropped with the cache generation by `ClearCache`. Additive gateway metadata about our fetch mechanics, not MXAccess behaviour — no synthesized event, so not a parity deviation. Docs: `gateway.md` alarm surface, `docs/DesignDecisions.md`. |
|
||||
| 2026-08-17 | **TST-15 → `Done` (discharges the ACL half of SEC-25): per-session dashboard event ACL shipped** (branch `feat/deferred-closeout`, commits `693a78d` + `7ec0b35`). Implements `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`, whose header is now `Implemented` with as-built notes in its §12. `IDashboardSessionAcl.CanViewSession` is the single decision **both** subscribe seams consult — `EventsHub.SubscribeSession` (denial is a `HubException`; the caller is neither joined to the group nor registered in `EventsHubViewerRegistry`, so the mirror stays off) and `SessionDetailsPage`'s in-process subscribe (inline denial, no subscription) — so neither path is the weaker one and the `TODO(per-session-acl)` is gone. Decision order, fail-closed on every branch: authenticated Administrator → allow (evaluated **before** the registry lookup, so Admin × unknown-session allows — pinned by a test because reordering the two checks is a plausible refactor); session not found → deny; untagged session → `Dashboard:UntaggedSessionVisibility` (`AdminOnly` default); else allow iff `session.Tags ∩ zb:dashboardtag` claims, ordinal-ignore-case. Session tags are inherited from the owning API key's `dashboard_tags` constraint (`apikey --dashboard-tags`, already in the `ApiKeyConstraints` JSON blob — no SQLite migration) and never from the client's wire request. Viewer grants come from `Dashboard:GroupToTag` over the user's LDAP groups, stamped at cookie login (`DashboardAuthenticator.CreatePrincipal`) and **re-resolved, not copied**, at hub-token mint (`HubTokenService.Issue`), so the 5-minute token lifetime bounds a stale grant. Anonymous localhost is an empty-grant Viewer; `Dashboard:DisableLogin` auto-login carries both roles and so takes the admin bypass unchanged. Tests: `DashboardSessionAclTests` (decision table, every branch asserted in its denying direction too), `EventsHubTests`, `DashboardAuthenticatorTests`, `HubTokenServiceTests`, a `GatewayOptionsTests` case proving `Dashboard:GroupToTag` keeps its ordinal-ignore-case lookup through configuration binding, and two `[LiveLdapFact]`s in `DashboardLdapLiveTests` that drive a real bind against the shared GLAuth (`gw-viewer` → `team-a` grant admits the `team-a` session and refuses the `team-b` one; `multi-role` bypasses on the sessions its own grant does not cover). The live pair needed **no GLAuth change** — the tag layer is config-side, keyed on the existing `GwAdmin`/`GwReader` groups (recorded in `glauth.md`). Docs: `docs/Sessions.md` (session-tag model), `gateway.md` + CLAUDE.md dashboard-auth paragraphs, `docs/GatewayDashboardDesign.md` (three passages that described the ACL as outstanding), `docs/GatewayConfiguration.md` (`ShowTagValues` row: redaction is now the second of two layers, not the only one), `docs/Authentication.md` (`--dashboard-tags` is the only *constraint* flag that splits on commas). |
|
||||
| 2026-08-10 | **TST-25 acceptance Check 6 (forced-failure nightly issue) → Done.** The 2026-07-13 record wrote this check off as "abandoned to shared-runner congestion"; that was wrong on both counts. The 2026-07-13 probe *did* land (issue #125, `[CHECK6 PROBE]`, run 375), and since 2026-07-17 the `nightly-windev` `if: failure()` step has filed an issue on **every** red nightly — #126–#139, all authored by the `gitea-actions` bot. Traced run 672 (schedule, main, red) line by line: main step fails → `exitcode '1': failure` → the `if: failure()` step runs → `POST /api/v1/repos/dohertj2/mxaccessgw/issues` with the built-in token masked to `***` → issue #139 created at the matching timestamp. Re-confirmed by a fresh forced-failure probe on the throwaway branch `test/tst25-check6-nightly-issue` (temporary `tst25-check6-probe.yml` reproducing the job shape with `exit 1` for the live step; run 677 → issue #140). Branch deleted, issues #125 and #140 closed with explanatory comments. **One real defect found and fixed** (`fix/tst25-nightly-issue-path`, not merged): `${{ github.server_url }}` is the runner-internal `http://gitea:3000`, so every filed issue's run link was unreachable from a browser. The API call must keep using it (the job container resolves `gitea` only on the docker network and has no LAN egress to the public origin), so the fix adds a `PUBLIC_SERVER_URL: https://gitea.dohertylan.com` job env used **only** for the browser-facing link in the issue body; the probe validated the fixed template (#140 carries a `https://gitea.dohertylan.com/...` link that returns 200). **Separately observed, not fixed:** the nightly has been red continuously since at least 2026-07-17 (run 672: `x86 Worker.Tests failed with exit code 1`, 1 failed / 398 passed / 11 skipped — the known `EventBurst_DrainLoopCoalescesFlushes` class of flake), and the step de-duplicates nothing, so 14 issues are open, seven of them (#132–#138) for the identical SHA `47c0b64`. Worth a follow-up: fix the red nightly, and consider having the step reuse an open issue with the same title instead of filing a new one. |
|
||||
| 2026-08-10 | **TST-24 → `Done`: per-client wire tests land for the two clients that lacked them** (branch `feat/tst-24-client-wire-tests`). Audit first corrected the finding's premise: **Go, Rust, and Java already had real-server wire tests** — `newBufconnClient`/`fakeGatewayServer` over `grpc/test/bufconn`, `spawn_fake_gateway` over a loopback `TcpListener` with tonic's `Server`, and `InProcessGateway`/`TestGatewayService` over `InProcessServerBuilder` — each already asserting the round trip, the server-observed `authorization` bearer header, and the `ReplayGap` sentinel. The real gaps were **.NET** (every test substituted `FakeGatewayTransport` for `IMxGatewayClientTransport`, and the test project had no server package) and **Python** (stub monkeypatching everywhere except one opt-in TLS test serving only `OpenSession`). Added `WireFakeGatewayServer` + `MxGatewayClientWireTests` (Kestrel h2c on `127.0.0.1:0` serving `MxAccessGatewayBase`; new `Grpc.AspNetCore.Server` 2.76.0 + `Microsoft.AspNetCore.App` refs on the test project) and `clients/python/tests/test_wire_fake_gateway.py` (`grpc.aio` server on `127.0.0.1:0`, no new deps). Four shapes each: full round trip with every reply field asserted, the bearer header **as received by the server** on the streaming RPC too, the `ReplayGap` sentinel surfaced as the client's typed signal, and a genuine `PERMISSION_DENIED` mapping to the typed authorization error. CI: the `portable` job only *built* the .NET client, so a `dotnet test` step was added. **The new tests immediately caught a shipped bug** — Python `GatewayClient.connect()`/`GalaxyRepositoryClient.connect()` constructed the `grpc.aio` channel inside `asyncio.to_thread`, which raises `RuntimeError: There is no current event loop in thread 'asyncio_0'` because a `grpc.aio` channel binds to the loop current on the constructing thread; every non-stub connection failed, and the one test guarding the off-loop behaviour (Client.Python-028) monkeypatched `create_channel` and so asserted the bug. Fixed by splitting `resolve_channel_security` (blocking TOFU probe, off-loop) from `create_channel` (on-loop), with the `-028` tests retargeted to assert both halves. Verified: .NET 133 passed/1 skipped (pre-existing live-gateway skip), Python 168 passed/1 skipped plus 6/6 opt-in TLS. Docs: `docs/GatewayTesting.md` § Client Wire Tests, `clients/dotnet/README.md`, `clients/python/README.md`. |
|
||||
| 2026-08-10 | **TST-05 revisited under the restored Windows tier → `Partially done`** (branch `feat/tst-24-client-wire-tests`, doc/tracker-only). The finding's **scheduling** half is closed: cycle-2 TST-25's `nightly-windev` job (cron `0 6 * * *`) runs `scripts/ci/run-windev-ci.sh live` → `windev-worker-ci.ps1 -Mode live`, which sets `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`, runs `WorkerLiveMxAccessSmokeTests` on windev after the x86 build/Worker.Tests/full-slnx steps, and files a Gitea issue when red. The **coverage-audit** half is *not* closed, and the audit the design asked for now has a negative answer: the suite's eight `[LiveMxAccessFact]`s cover all six late-added COM commands (`Suspend`, `Activate`, `AuthenticateUser`, `ArchestrAUserToId`, `AddBufferedItem`, `SetBufferedUpdateInterval`) but zero of the five control commands — `MxCommandKind.{Ping,GetSessionState,GetWorkerInfo,DrainEvents,ShutdownWorker}` appear nowhere in `WorkerLiveMxAccessSmokeTests.cs`, so the exact paths the Finding calls masked are still only proven against `FakeWorkerHarness` canned replies while the real implementations live in `Worker/Ipc/WorkerPipeSession.cs`. Residual work (two `[LiveMxAccessFact]`s, windev-only to author and verify) is specified in [60-testing-docs-gaps.md](60-testing-docs-gaps.md#tst-05--real-worker-controlcom-paths-verified-opt-in-only---medium--p1). |
|
||||
|
||||
@@ -330,6 +330,8 @@ Open question the review cannot settle: whether a future probe (COM-init readine
|
||||
|
||||
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Server`; `dotnet test src/ZB.MOM.WW.MxGateway.Tests --filter "FullyQualifiedName~SessionManager"`.
|
||||
|
||||
**Resolution: Won't fix (2026-08-20, closed by cycle-3 verification).** `Grpc/MxAccessGatewayService.cs:111-115` carries a `PERF(followup)` comment documenting the double session lookup as measured-negligible and deliberately not collapsed — the fix would duplicate `InvokeAsync`'s fault mapping at a second entry point, trading a negligible per-command cost for a maintenance liability. Per the cycle-3 gateway-core review (`archreview/2026-08-19/10-gateway-core.md`, GWC-16 row), the rationale is sound; tracker flipped from `Not started` to `Won't fix`.
|
||||
|
||||
---
|
||||
|
||||
## GWC-17 — Sessions layer throws `Grpc.Core.RpcException` `Low` · `—`
|
||||
|
||||
@@ -175,6 +175,8 @@ The frame protocol and proto evolution hygiene are sound; the remediable risk cl
|
||||
|
||||
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Server` + `dotnet test ...Tests --filter FullyQualifiedName~WorkerClient`; worker side `-p:Platform=x86`.
|
||||
|
||||
**Resolution: Done (2026-08-20, closed by cycle-3 verification, residual filed).** Wired up substantially as designed: `InvokeAsync` forwards a `WorkerCancel` when the gateway's own command timeout fires and it owned the removal (`Server/Workers/WorkerClient.cs:269-291`), `TrySendCancelForTimedOutCommand` is structurally no-throw (`:835-870`), the worker routes it to `CancelCommand` (`Worker/Ipc/WorkerPipeSession.cs:755-756` → `MxAccess/MxAccessStaSession.cs:529-532` → `Sta/StaCommandDispatcher.CancelQueuedCommand:151`), regression test `WorkerClientTests.InvokeAsync_WhenCommandTimesOut_SendsWorkerCancelForThatCorrelation` exists, and `docs/GatewayProcessDesign.md:596-602` documents it. Landed in `7c1ea12`/`a1a38b5`. **Residual, not covered by this closure:** the caller-cancellation leg (context deadline / client disconnect, as opposed to the gateway's own `CommandTimeout`) sends nothing — filed as cycle-3 **IPC-34**. Per `archreview/2026-08-19/30-contracts-ipc.md` (IPC-08 row), tracker flipped from `Not started` to `Done` with the residual cross-referenced.
|
||||
|
||||
---
|
||||
|
||||
## IPC-09 — Known codegen fragilities have no in-repo guards `Medium` · `P1`
|
||||
|
||||
@@ -457,6 +457,8 @@ This document turns every finding in the Security/Dashboard/Observability review
|
||||
- Tests: broadcaster test asserting values redacted when `ShowTagValues=false`.
|
||||
- Docs: `docs/GatewayDashboardDesign.md` — clarify the current v1 posture.
|
||||
|
||||
**Update 2026-08-17 — the deferred half landed.** The scoping mechanism this finding waited on shipped as TST-15 (`693a78d` + `7ec0b35`): `IDashboardSessionAcl` gates `SubscribeSession` *and* the session-details page's in-process subscribe, so the `TODO(per-session-acl)` is gone and the redaction is no longer the only thing between a low-trust Viewer and another session's events. See the TST-15 section in [60-testing-docs-gaps.md](60-testing-docs-gaps.md#tst-15--dashboard-eventshub-has-no-per-session-acl) and the 2026-08-17 change-log row in [00-tracking.md](00-tracking.md#change-log). Redaction stays — the two layers are independent: the ACL decides who may subscribe, `ShowTagValues` decides what a permitted subscriber sees.
|
||||
|
||||
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Server` and the events-hub/broadcaster test filter.
|
||||
|
||||
---
|
||||
|
||||
@@ -191,6 +191,8 @@ This finding is the umbrella; TST-01/02/15 are its actionable slices. The remedi
|
||||
|
||||
**Verification.** `dotnet test ... --filter FullyQualifiedName~DashboardLiveDataServiceTests`.
|
||||
|
||||
**Status refresh (2026-08-20, per cycle-3 verification).** Materially narrowed — the tracker row was stale. `Tests/Gateway/Dashboard/DashboardLiveDataServiceTests.cs`, `EventsHubTests.cs`, `EventsHubViewerRegistryTests.cs`, `AlarmsHubPublisherTests.cs`, and `SessionDetailsPageEventAclTests.cs` all exist now (mostly landed with the SEC-25/TST-16 closeouts and the in-process dashboard-feed work). Per `archreview/2026-08-19/60-testing-docs-gaps.md` (TST-06 row), this needs a re-audit-and-close pass against the current coverage rather than fresh implementation — left open pending that audit, tracker status changed from `Not started` to `Materially narrowed` to reflect the actual state.
|
||||
|
||||
---
|
||||
|
||||
## TST-07 — Real-clock sleeps with negative assertions are latent flakes `Medium` · `—`
|
||||
@@ -335,11 +337,11 @@ If TST-02's interim mitigation (flip retention off) is chosen instead of impleme
|
||||
|
||||
**Impact.** Acceptable for a single-tenant dashboard; wrong the moment `GroupToRole` admits low-trust viewers. It is the dashboard-side twin of the gRPC owner-revalidation gap (TST-02).
|
||||
|
||||
**Design.** Fully fleshed out in `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` (epic Phase 4, Tasks 16–19, TST-04). In brief: the dashboard authenticates LDAP users (Admin/Viewer) while sessions are API-key-owned (`OwnerKeyId`) — two disjoint identity domains — so the ACL needs a bridge: a **session tag** sourced from the owning API key (riding in the existing `ApiKeyConstraints` JSON blob, no SQLite migration). Admin sees all; a Viewer may `SubscribeSession` iff `session.Tags ∩ viewer.GrantedTags ≠ ∅` (granted via a new `Dashboard:GroupToTag` map, carried into the hub token as tag claims); untagged sessions are Admin-only by default. The Viewer-default decision (admin-sees-all vs strict) is settled there. Until Phase 4 lands, keep the TODO (it correctly documents the accepted single-tenant assumption); do not silently remove it.
|
||||
**Design.** Fully fleshed out in `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` (epic Phase 4, Tasks 16–19, TST-04). In brief: the dashboard authenticates LDAP users (Admin/Viewer) while sessions are API-key-owned (`OwnerKeyId`) — two disjoint identity domains — so the ACL needs a bridge: a **session tag** sourced from the owning API key (riding in the existing `ApiKeyConstraints` JSON blob, no SQLite migration). Admin sees all; a Viewer may `SubscribeSession` iff `session.Tags ∩ viewer.GrantedTags ≠ ∅` (granted via a new `Dashboard:GroupToTag` map, carried into the hub token as tag claims); untagged sessions are Admin-only by default. The Viewer-default decision (admin-sees-all vs strict) is settled there.
|
||||
|
||||
**Implementation.** `Dashboard/Hubs/EventsHub.cs` (ACL check on group join), hub-token minting to carry the session tag, `Configuration/DashboardOptions.cs` for any group-to-tag config (Task 17). Tests: `...Tests/Gateway/Dashboard/` hub ACL cases incl. live-LDAP users (Task 19). Docs: `docs/Sessions.md`/`gateway.md` dashboard section document the ACL model; CLAUDE.md dashboard-auth paragraph.
|
||||
**Implementation.** Shipped 2026-08-17 on `feat/deferred-closeout` (`693a78d` + `7ec0b35`); the `TODO(per-session-acl)` is gone. `Dashboard/IDashboardSessionAcl.cs` + `Dashboard/DashboardSessionAcl.cs` hold the single decision, consulted by `Dashboard/Hubs/EventsHub.cs` (`SubscribeSession` → `HubException` on denial, no group join and no viewer registration) and by `Dashboard/Components/Pages/SessionDetailsPage.razor`'s in-process subscribe — the design's one correction, since the page path was not a hub client and would otherwise have been the unguarded seam. Tags ride from the owning key via `ISessionManager.OpenSessionAsync`'s tagged overload into the immutable `GatewaySession.Tags`; grants are stamped by `DashboardAuthenticator.CreatePrincipal` and re-resolved at `HubTokenService.Issue`. Config: `Dashboard:GroupToTag` and `Dashboard:UntaggedSessionVisibility` on `Configuration/DashboardOptions.cs`. Tests: `Tests/Gateway/Dashboard/DashboardSessionAclTests.cs`, `EventsHubTests.cs`, a `Configuration/GatewayOptionsTests.cs` binding case for the `GroupToTag` comparer, and two `[LiveLdapFact]`s in `IntegrationTests/DashboardLdapLiveTests.cs`. Docs: `docs/Sessions.md`, `gateway.md`, CLAUDE.md, `docs/GatewayDashboardDesign.md`, `docs/GatewayConfiguration.md`, `glauth.md`.
|
||||
|
||||
**Verification.** `dotnet test ... --filter FullyQualifiedName~EventsHub`; `dotnet build src/ZB.MOM.WW.MxGateway.Server`.
|
||||
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`; `dotnet test ... --filter FullyQualifiedName~DashboardSessionAclTests`, `~EventsHubTests`, `~GatewayOptionsTests`; live-LDAP pair run green against the shared GLAuth with `MXGATEWAY_RUN_LIVE_LDAP_TESTS=1` (and skipping cleanly without it).
|
||||
|
||||
---
|
||||
|
||||
@@ -355,6 +357,8 @@ If TST-02's interim mitigation (flip retention off) is chosen instead of impleme
|
||||
|
||||
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Server`; test toggling the flag suppresses/shows values; docs match.
|
||||
|
||||
**Closed 2026-08-18 — implemented (the Design's recommended path).** The flag is no longer dead: it now gates value display on all three seams that carry a tag value. `Dashboard/DashboardLiveDataService.cs` substitutes `[redacted]` at the service boundary for each successfully read `/browse` tag (a failed read keeps its `-` placeholder, so the error row and the redaction cannot contradict each other); `Dashboard/Hubs/AlarmsHubPublisher.cs` clears `current_value`/`limit_value` from a **deep-cloned** `AlarmFeedMessage` before broadcasting to `/hubs/alarms`, and `DashboardEventBroadcaster` does the same for the events-hub mirror — clones, because the sources fan out to gRPC `StreamAlarms`/`StreamEvents` and the replay ring, so no gRPC client is affected by this dashboard-display flag. The flag is **kept**, default still `false`. The stale prose the Finding flagged is gone: `docs/GatewayConfiguration.md` and `docs/GatewayDashboardDesign.md` now describe the real behaviour. Status tracked in `00-tracking.md` (already **Done**).
|
||||
|
||||
---
|
||||
|
||||
## TST-17 — Vendor-gated alarm parity residuals silently lossy `Medium` · `—`
|
||||
@@ -369,6 +373,8 @@ If TST-02's interim mitigation (flip retention off) is chosen instead of impleme
|
||||
|
||||
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Worker -p:Platform=x86`; `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/... -p:Platform=x86` (Windows).
|
||||
|
||||
**Status refresh (2026-08-20, per cycle-3 verification).** Materially changed — the tracker row needs a refresh from the worker domain. `WnWrapAlarmConsumer.cs:309-327` now passes `szDomainName`/`szOprFullName` into the COM call (the `_ = ackOperatorDomain` discard is gone), and the 2026-08 probe cycle recorded the vendor 8-arg leg as "unavailable by configuration" with evidence (`docs/AlarmProbeFindings.md`, commits `d1ae43d`, `540a118`). Per `archreview/2026-08-19/60-testing-docs-gaps.md` (TST-17 row), "silent drop, unchanged" is no longer an accurate description of the row; whether a caller-visible degrade signal now exists is the worker reviewer's call to close out. Tracker status changed from `Not started` to `Materially changed`.
|
||||
|
||||
---
|
||||
|
||||
## TST-18 — Hosted-service wrappers untested `Low` · `—`
|
||||
|
||||
@@ -122,6 +122,11 @@ The .NET client applies a bounded Polly retry policy only to idempotent calls:
|
||||
streams, writes, secured writes, authentication, registration, item management,
|
||||
or subscription changes because those calls can partially succeed in MXAccess.
|
||||
|
||||
Each attempt carries its own `DefaultCallTimeout` deadline. The overall budget
|
||||
for a retried call is `MaxAttempts × DefaultCallTimeout` plus `MaxDelay` for each
|
||||
backoff, so an attempt that ends in `DeadlineExceeded` leaves the retry its own
|
||||
full deadline instead of consuming the whole budget.
|
||||
|
||||
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
|
||||
library constructor unless a helper explicitly says it does that.
|
||||
|
||||
|
||||
@@ -137,18 +137,68 @@ gateway's own sentinel observable. Normal events pass through with
|
||||
`IsReplayGap == false` and `ReplayGap == null`. The gap is only ever produced by
|
||||
`StreamEvents`; the diagnostic drain path never emits it. If you already consume
|
||||
the raw `StreamEventsAsync` (or the client-level stream), the
|
||||
### Caller-owned channels, resuming sessions, diagnostics
|
||||
|
||||
`MxGatewayClient.Create(GrpcChannel, MxGatewayClientOptions)` and
|
||||
`GalaxyRepositoryClient.Create(GrpcChannel, MxGatewayClientOptions)` build a
|
||||
client over a channel the caller created (shared channel, custom handler or
|
||||
TLS). The client never disposes a caller-owned channel; `Create(options)` still
|
||||
creates and owns its own channel.
|
||||
|
||||
`MxGatewayClient.AttachSession(sessionId)` returns an `MxGatewaySession` bound
|
||||
to an existing session id without calling OpenSession, so a restarted process
|
||||
can resume `StreamEventsAsync(afterWorkerSequence)`. The gateway only allows the
|
||||
API key that opened the session to attach; any other key gets `PermissionDenied`.
|
||||
The attached session's `OpenSessionReply` carries only the session id.
|
||||
|
||||
`MxGatewaySession` has typed diagnostics: `PingAsync`, `GetSessionStateAsync`,
|
||||
`GetWorkerInfoAsync`, and `DrainEventsAsync` (removes queued events from the
|
||||
worker; call until empty). `DisposeAsync` closes the session best-effort and does
|
||||
not throw when the gateway is unreachable or the client is already disposed.
|
||||
|
||||
Retry applies only to idempotent calls (`CloseSessionRawAsync`, ping,
|
||||
session-state and worker-info commands, Galaxy reads). Each attempt gets its own
|
||||
`DefaultCallTimeout`; a retried call can take up to `Retry.MaxAttempts ×
|
||||
DefaultCallTimeout` plus the backoff between attempts. `AcknowledgeAlarmAsync`
|
||||
is never retried because it is a non-idempotent operator write that re-sends the
|
||||
operator password and can double-acknowledge in the Historian.
|
||||
|
||||
`AsStreamItemsAsync()` extension projects any `IAsyncEnumerable<MxEvent>` into
|
||||
the same `MxEventStreamItem` surface.
|
||||
|
||||
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
|
||||
the active alarms the gateway's central monitor currently holds),
|
||||
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
|
||||
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
|
||||
reference, optional comment, ack target). All three accept a cancellation
|
||||
keyed by the same monitor), and `AcknowledgeAlarmAsync` (acknowledges as
|
||||
the operator: the gateway authenticates `OperatorUser`/`OperatorPassword` and
|
||||
writes the comment to the alarm's `AckMsg`, so the Historian records the
|
||||
operator). The operator's full name is not sent by the client and is not propagated to MXAccess (`operator_full_name` on the worker commands is accepted but ignored); the Historian takes it from the authenticated Galaxy account. All three accept a cancellation
|
||||
token and pass through the `MxGateway:Alarms` configuration on the
|
||||
server — when alarms are disabled, the gateway returns an empty list / empty
|
||||
stream rather than failing.
|
||||
|
||||
`ActiveAlarmSnapshot.FromTruncatedSnapshot` marks a record that came from a
|
||||
provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
||||
alarms, and the gateway suspended its absence-implies-cleared inference for that
|
||||
poll. Treat the set as possibly incomplete rather than reconciling deletions
|
||||
from it. It is set-level degraded status, not a comment on the record's own
|
||||
fidelity, and is distinct from `Degraded` (the subtag fallback provider).
|
||||
|
||||
`StreamAlarmsAsync` also carries that completeness verdict at feed level, as an
|
||||
`AlarmFeedMessage.PayloadOneofCase.SnapshotStatus` frame whose
|
||||
`SnapshotStatus.Truncated` is true while the monitor's cached set derives from a
|
||||
truncated fetch. One arrives at stream open (after the `ProviderStatus` frame,
|
||||
before the cached `ActiveAlarm` frames) so a late joiner learns the current
|
||||
verdict, then one on every verdict change — including the clearing frame sent
|
||||
when the gateway's alarm monitor restarts and drops a truncated verdict. Track
|
||||
it if you need set completeness on a live feed without polling
|
||||
`QueryActiveAlarmsAsync`.
|
||||
|
||||
The CLI's text rows always spell out `degraded=false` and `truncated=false` so
|
||||
healthy and clearing frames remain visible. Its protobuf-JSON modes retain the
|
||||
standard proto3 omission rule, so those same false-valued payloads appear as
|
||||
`{"providerStatus": {}}` and `{"snapshotStatus": {}}`; absent means false.
|
||||
|
||||
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
|
||||
the first `CloseSessionReply` instead of sending another close request.
|
||||
|
||||
@@ -268,11 +318,16 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- advise --s
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --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-alarms --filter-prefix Area001 --max-events 1 --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 -- 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 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||
```
|
||||
|
||||
`acknowledge-alarm` reads the operator password from `--operator-password` or the
|
||||
environment variable named by `--operator-password-env` (default
|
||||
`MXGATEWAY_OPERATOR_PASSWORD`); the password is never echoed and is redacted from
|
||||
any error output. Prefer the environment variable to keep it off the command line.
|
||||
|
||||
`smoke` opens a session, registers a client, adds one item, advises it,
|
||||
optionally writes a value when `--type` and `--value` are supplied, reads a
|
||||
bounded event stream, and closes the session in a `finally` block. CLI error
|
||||
@@ -501,7 +556,7 @@ dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget
|
||||
Then add the package to your project:
|
||||
|
||||
````bash
|
||||
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.2.0
|
||||
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.3.0
|
||||
````
|
||||
|
||||
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
|
||||
|
||||
@@ -185,6 +185,7 @@ public static class MxGatewayClientCli
|
||||
exception.Message,
|
||||
apiKey,
|
||||
TryResolveVerifyUserPassword(arguments),
|
||||
ResolveOperatorPassword(arguments),
|
||||
arguments.GetOptional("value"));
|
||||
|
||||
if (forceJsonErrors || arguments.HasFlag("json"))
|
||||
@@ -347,13 +348,19 @@ public static class MxGatewayClientCli
|
||||
|
||||
/// <summary>
|
||||
/// Canonical CLI credential environment variable, shared by every official
|
||||
/// client CLI (CLI-45) so one exported variable drives the same operator
|
||||
/// client CLI so one exported variable drives the same operator
|
||||
/// workflow in all five languages.
|
||||
/// </summary>
|
||||
private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Pre-CLI-45 environment variable, still honoured as a deprecated fallback
|
||||
/// Default environment variable holding the operator password for
|
||||
/// <c>acknowledge-alarm</c> when <c>--operator-password-env</c> is not given.
|
||||
/// </summary>
|
||||
private const string OperatorPasswordEnvironmentName = "MXGATEWAY_OPERATOR_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Legacy environment variable, still honoured as a deprecated fallback
|
||||
/// for one release so existing scripts keep working.
|
||||
/// </summary>
|
||||
private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD";
|
||||
@@ -379,14 +386,38 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective MXAccess verify-user credential in the CLI-45
|
||||
/// Resolves the <c>acknowledge-alarm</c> operator password: <c>--operator-password</c>,
|
||||
/// then the environment variable named by <c>--operator-password-env</c>
|
||||
/// (default <c>MXGATEWAY_OPERATOR_PASSWORD</c>). An empty value is treated as
|
||||
/// absent. The value is never echoed; it is also fed to the error redactor.
|
||||
/// </summary>
|
||||
private static string? ResolveOperatorPassword(CliArguments arguments)
|
||||
{
|
||||
string? password = arguments.GetOptional("operator-password");
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
return password;
|
||||
}
|
||||
|
||||
string environmentName = arguments.GetOptional("operator-password-env") ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(environmentName))
|
||||
{
|
||||
environmentName = OperatorPasswordEnvironmentName;
|
||||
}
|
||||
|
||||
password = Environment.GetEnvironmentVariable(environmentName);
|
||||
return string.IsNullOrEmpty(password) ? null : password;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective MXAccess verify-user credential in this
|
||||
/// order: <c>--password</c>, the deprecated <c>--verify-user-password</c>
|
||||
/// alias, the environment variable named by <c>--password-env</c> (default
|
||||
/// <c>MXGATEWAY_VERIFY_PASSWORD</c>), then the deprecated
|
||||
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>. An empty value from any source is
|
||||
/// treated as absent. The credential is never echoed; this resolver exists so
|
||||
/// the error-redaction catch block can strip it from any surfaced error
|
||||
/// (CLI-04), mirroring <see cref="TryResolveApiKey" />.
|
||||
/// the error-redaction catch block can strip it from any surfaced error,
|
||||
/// mirroring <see cref="TryResolveApiKey" />.
|
||||
/// </summary>
|
||||
private static string? TryResolveVerifyUserPassword(CliArguments arguments)
|
||||
{
|
||||
@@ -416,7 +447,7 @@ public static class MxGatewayClientCli
|
||||
/// Resolves the verify-user credential for <c>authenticate-user</c>, throwing
|
||||
/// a redaction-safe error when no source yields a non-empty value. Failing
|
||||
/// fast keeps a misconfigured environment from becoming a real MXAccess
|
||||
/// authentication attempt with an empty credential (CLI-45); the thrown
|
||||
/// authentication attempt with an empty credential; the thrown
|
||||
/// message names only the option/env var, never the value.
|
||||
/// </summary>
|
||||
private static string ResolveVerifyUserPassword(CliArguments arguments)
|
||||
@@ -758,9 +789,9 @@ public static class MxGatewayClientCli
|
||||
{
|
||||
// The credential is resolved from --password or its env var (default
|
||||
// MXGATEWAY_VERIFY_PASSWORD) and is never echoed; a missing or empty value
|
||||
// fails fast before the invoke rather than reaching the wire (CLI-45).
|
||||
// fails fast before the invoke rather than reaching the wire.
|
||||
// On any surfaced error the RunCoreAsync catch block routes
|
||||
// it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04).
|
||||
// it through MxGatewayCliSecretRedactor so it cannot reach stderr.
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
@@ -1425,7 +1456,7 @@ public static class MxGatewayClientCli
|
||||
else if (gatewayEvent.ReplayGap is { } replayGap)
|
||||
{
|
||||
// Render the ReplayGap sentinel as the typed cross-CLI row instead of the raw
|
||||
// sentinel MxEvent (NEXT-02, mirroring the Go/Python/Rust CLIs).
|
||||
// sentinel MxEvent, mirroring the Go/Python/Rust CLIs.
|
||||
output.WriteLine(FormatReplayGapRow(replayGap));
|
||||
}
|
||||
else
|
||||
@@ -1535,6 +1566,8 @@ public static class MxGatewayClientCli
|
||||
AlarmFullReference = arguments.GetRequired("reference"),
|
||||
Comment = arguments.GetOptional("comment") ?? string.Empty,
|
||||
OperatorUser = arguments.GetOptional("operator") ?? string.Empty,
|
||||
OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty,
|
||||
OperatorPassword = ResolveOperatorPassword(arguments) ?? string.Empty,
|
||||
};
|
||||
|
||||
return WriteReplyAsync(
|
||||
@@ -1546,8 +1579,9 @@ public static class MxGatewayClientCli
|
||||
/// <summary>
|
||||
/// Renders one <see cref="AlarmFeedMessage"/> for the human-readable
|
||||
/// (non-JSON) stream-alarms output, distinguishing the <c>payload</c> oneof
|
||||
/// arms: a snapshot active alarm, the snapshot-complete sentinel, or a live
|
||||
/// transition.
|
||||
/// arms: a snapshot active alarm, the snapshot-complete sentinel, a live
|
||||
/// transition, the provider-mode status, or the feed-level
|
||||
/// snapshot-completeness status.
|
||||
/// </summary>
|
||||
private static string FormatAlarmFeedMessage(AlarmFeedMessage feedMessage)
|
||||
{
|
||||
@@ -1559,6 +1593,10 @@ public static class MxGatewayClientCli
|
||||
$"snapshot-complete {feedMessage.SnapshotComplete}",
|
||||
AlarmFeedMessage.PayloadOneofCase.Transition =>
|
||||
$"transition {ProtobufJsonFormatter.Format(feedMessage.Transition)}",
|
||||
AlarmFeedMessage.PayloadOneofCase.ProviderStatus =>
|
||||
$"provider-status mode={feedMessage.ProviderStatus.Mode} degraded={feedMessage.ProviderStatus.Degraded.ToString().ToLowerInvariant()} reason=\"{feedMessage.ProviderStatus.Reason}\"",
|
||||
AlarmFeedMessage.PayloadOneofCase.SnapshotStatus =>
|
||||
$"snapshot-status truncated={feedMessage.SnapshotStatus.Truncated.ToString().ToLowerInvariant()}",
|
||||
_ => $"unknown-payload {feedMessage.PayloadCase}",
|
||||
};
|
||||
}
|
||||
@@ -1844,7 +1882,7 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the typed ReplayGap row shared by the CLIs (NEXT-02). Hand-built so the
|
||||
/// Formats the typed ReplayGap row shared by the CLIs. Hand-built so the
|
||||
/// cursors are JSON numbers like the Go/Python/Rust rows, not the protobuf JSON
|
||||
/// formatter's quoted uint64 strings.
|
||||
/// </summary>
|
||||
@@ -2460,7 +2498,7 @@ public static class MxGatewayClientCli
|
||||
writer.WriteLine("mxgw-dotnet bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>] [--tag-start <n>] [--tag-prefix <s>] [--tag-attribute <s>] [--timeout-ms <n>] [--client-name <name>]");
|
||||
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
|
||||
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> --operator <user> [--operator-domain <dom>] [--operator-password <pw> | --operator-password-env <VAR>] [--comment <text>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
|
||||
|
||||
@@ -36,6 +36,8 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = "investigating",
|
||||
OperatorUser = "alice",
|
||||
OperatorPassword = "pw-secret",
|
||||
OperatorDomain = "CORP",
|
||||
});
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
@@ -45,32 +47,56 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Equal("Tank01.Level.HiHi", call.Request.AlarmFullReference);
|
||||
Assert.Equal("investigating", call.Request.Comment);
|
||||
Assert.Equal("alice", call.Request.OperatorUser);
|
||||
Assert.Equal("pw-secret", call.Request.OperatorPassword);
|
||||
Assert.Equal("CORP", call.Request.OperatorDomain);
|
||||
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync redacts the operator password from a failing call's message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_FailureMessage_DoesNotContainOperatorPassword()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
|
||||
// The real transport maps RpcException to MxGatewayException using the status
|
||||
// detail; the fake passes exceptions verbatim, so enqueue the mapped shape.
|
||||
transport.AcknowledgeAlarmExceptions.Enqueue(new MxGatewayException("boom pw-secret"));
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
|
||||
MxGatewayException ex = await Assert.ThrowsAsync<MxGatewayException>(
|
||||
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
OperatorUser = "alice",
|
||||
OperatorPassword = "pw-secret",
|
||||
}));
|
||||
|
||||
Assert.DoesNotContain("pw-secret", ex.Message);
|
||||
Assert.Contains("boom", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
||||
{
|
||||
// Acks are routed through the safe-unary retry pipeline (idempotent at the
|
||||
// MxAccess level), so the transport-side cancellation token is a linked one
|
||||
// rather than the caller's original. Verify cancellation by tripping the source
|
||||
// and asserting the call observes it.
|
||||
// Acks bypass the retry pipeline (non-idempotent), so the caller's token
|
||||
// reaches the transport unchanged.
|
||||
using CancellationTokenSource cancellation = new();
|
||||
cancellation.Cancel();
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
client.AcknowledgeAlarmAsync(
|
||||
new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = string.Empty,
|
||||
OperatorUser = "alice",
|
||||
},
|
||||
cancellation.Token));
|
||||
await client.AcknowledgeAlarmAsync(
|
||||
new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = string.Empty,
|
||||
OperatorUser = "alice",
|
||||
},
|
||||
cancellation.Token);
|
||||
|
||||
Assert.Equal(cancellation.Token, Assert.Single(transport.AcknowledgeAlarmCalls).CallOptions.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
|
||||
|
||||
@@ -134,6 +134,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand()
|
||||
{
|
||||
@@ -178,6 +179,7 @@ public sealed class MxGatewayClientCliTests
|
||||
/// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
|
||||
/// credential from the flag, and that the credential never appears in stdout/stderr.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential()
|
||||
{
|
||||
@@ -218,9 +220,10 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-04: a surfaced error for authenticate-user must have the credential
|
||||
/// Verifies that a surfaced error for authenticate-user must have the credential
|
||||
/// redacted (never echoed to stderr), mirroring the API-key redaction seam.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential()
|
||||
{
|
||||
@@ -248,7 +251,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: <c>--password</c> is the primary credential flag, matching the other
|
||||
/// <c>--password</c> is the primary credential flag, matching the other
|
||||
/// four CLIs. The credential reaches the wire but never stdout/stderr.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
@@ -290,10 +293,12 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: the credential is read from the environment variable named by
|
||||
/// the credential is read from the environment variable named by
|
||||
/// <c>--password-env</c>, whose default is the canonical
|
||||
/// <c>MXGATEWAY_VERIFY_PASSWORD</c> shared by all five CLIs.
|
||||
/// </summary>
|
||||
/// <param name="passwordEnvArgument">The value passed to <c>--password-env</c>, or null to use the default.</param>
|
||||
/// <param name="environmentName">The environment variable that holds the credential.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(null, "MXGATEWAY_VERIFY_PASSWORD")]
|
||||
@@ -340,7 +345,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: the pre-rename names stay usable for one release — the deprecated
|
||||
/// the pre-rename names stay usable for one release — the deprecated
|
||||
/// <c>--verify-user-password</c> flag and the deprecated
|
||||
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c> environment variable both still resolve.
|
||||
/// </summary>
|
||||
@@ -413,7 +418,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: a missing or empty credential fails fast before the invoke — the CLI
|
||||
/// a missing or empty credential fails fast before the invoke — the CLI
|
||||
/// never sends a fabricated empty password to the wire. The error names the flag
|
||||
/// and the environment variable, never a value.
|
||||
/// </summary>
|
||||
@@ -463,6 +468,9 @@ public sealed class MxGatewayClientCliTests
|
||||
private readonly string _name;
|
||||
private readonly string? _original;
|
||||
|
||||
/// <summary>Sets the environment variable and remembers its original value.</summary>
|
||||
/// <param name="name">The environment variable name.</param>
|
||||
/// <param name="value">The temporary value, or null to clear it.</param>
|
||||
public EnvironmentVariableScope(string name, string? value)
|
||||
{
|
||||
_name = name;
|
||||
@@ -470,6 +478,7 @@ public sealed class MxGatewayClientCliTests
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
|
||||
/// <summary>Restores the original environment variable value.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(_name, _original);
|
||||
@@ -588,7 +597,7 @@ public sealed class MxGatewayClientCliTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies stream-events renders the ReplayGap sentinel as the typed cross-CLI row —
|
||||
/// numeric cursors under a replayGap key — instead of the raw sentinel MxEvent (NEXT-02).
|
||||
/// numeric cursors under a replayGap key — instead of the raw sentinel MxEvent.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
@@ -705,11 +714,56 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that false alarm-feed status flags remain explicit in text while protobuf JSON omits defaults.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_StreamAlarms_StatusFramesDistinguishTextFromProtobufJson()
|
||||
{
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
||||
{
|
||||
ProviderStatus = new AlarmProviderStatus { Degraded = false },
|
||||
});
|
||||
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
||||
{
|
||||
SnapshotStatus = new AlarmSnapshotStatus { Truncated = false },
|
||||
});
|
||||
|
||||
using var textOutput = new StringWriter();
|
||||
using var textError = new StringWriter();
|
||||
int textExit = await MxGatewayClientCli.RunAsync(
|
||||
["stream-alarms", "--endpoint", "http://localhost:5000", "--api-key", "test-api-key", "--max-events", "2"],
|
||||
textOutput,
|
||||
textError,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, textExit);
|
||||
Assert.Equal(
|
||||
["provider-status mode=Unspecified degraded=false reason=\"\"", "snapshot-status truncated=false"],
|
||||
textOutput.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries));
|
||||
Assert.Equal(string.Empty, textError.ToString());
|
||||
|
||||
using var jsonOutput = new StringWriter();
|
||||
using var jsonError = new StringWriter();
|
||||
int jsonExit = await MxGatewayClientCli.RunAsync(
|
||||
["stream-alarms", "--endpoint", "http://localhost:5000", "--api-key", "test-api-key", "--max-events", "2", "--jsonl"],
|
||||
jsonOutput,
|
||||
jsonError,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, jsonExit);
|
||||
Assert.Equal(
|
||||
["{ \"providerStatus\": { } }", "{ \"snapshotStatus\": { } }"],
|
||||
jsonOutput.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries));
|
||||
Assert.Equal(string.Empty, jsonError.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("MXGW_TEST_OPPW", "op-pw-secret");
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
@@ -733,6 +787,10 @@ public sealed class MxGatewayClientCliTests
|
||||
"ack from cli",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password-env",
|
||||
"MXGW_TEST_OPPW",
|
||||
"--json",
|
||||
],
|
||||
output,
|
||||
@@ -744,6 +802,9 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
||||
Assert.Equal("ack from cli", request.Comment);
|
||||
Assert.Equal("operator1", request.OperatorUser);
|
||||
Assert.Equal("CORP", request.OperatorDomain);
|
||||
Assert.Equal("op-pw-secret", request.OperatorPassword);
|
||||
Assert.DoesNotContain("op-pw-secret", output.ToString());
|
||||
Assert.Contains("ack-fixture", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,39 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
public sealed class MxGatewayClientOptionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The safe-unary budget covers every attempt's deadline plus the backoff between them, so a
|
||||
/// first attempt that hits its deadline does not consume the retry's time (#197).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SafeUnaryBudget_CoversEveryAttemptAndBackoff()
|
||||
{
|
||||
var options = new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri("http://localhost:5000"),
|
||||
ApiKey = "test-api-key",
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(30),
|
||||
Retry = new MxGatewayClientRetryOptions { MaxAttempts = 3, MaxDelay = TimeSpan.FromSeconds(2) },
|
||||
};
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(94), MxGatewayClientRetryPolicy.SafeUnaryBudget(options));
|
||||
}
|
||||
|
||||
/// <summary>A budget beyond what a cancellation timer accepts means no outer cap rather than a throw.</summary>
|
||||
[Fact]
|
||||
public void SafeUnaryBudget_WhenTooLargeForATimer_ReturnsNull()
|
||||
{
|
||||
var options = new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri("http://localhost:5000"),
|
||||
ApiKey = "test-api-key",
|
||||
DefaultCallTimeout = TimeSpan.FromDays(40),
|
||||
Retry = new MxGatewayClientRetryOptions { MaxAttempts = 2 },
|
||||
};
|
||||
|
||||
Assert.Null(MxGatewayClientRetryPolicy.SafeUnaryBudget(options));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that options with valid endpoint and API key pass validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds()
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>Tests for ack no-retry, caller-owned channels, safe dispose, typed diagnostics, and AttachSession.</summary>
|
||||
public sealed class MxGatewayClientRemediationTests
|
||||
{
|
||||
/// <summary>An acknowledge is a non-idempotent operator write and is never retried.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_DoesNotRetryTransientFailure()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AcknowledgeAlarmExceptions.Enqueue(new RpcException(new Status(StatusCode.Unavailable, "down")));
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
|
||||
await Assert.ThrowsAsync<RpcException>(() => client.AcknowledgeAlarmAsync(
|
||||
new AcknowledgeAlarmRequest { OperatorUser = "op", OperatorPassword = "pw" }));
|
||||
|
||||
Assert.Single(transport.AcknowledgeAlarmCalls);
|
||||
}
|
||||
|
||||
/// <summary>A client created over a caller-owned channel does not dispose it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Create_WithExistingChannel_DoesNotDisposeChannel()
|
||||
{
|
||||
using GrpcChannel channel = GrpcChannel.ForAddress("http://localhost:1");
|
||||
MxGatewayClient gateway = MxGatewayClient.Create(channel, Options());
|
||||
GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.Create(channel, Options());
|
||||
|
||||
await gateway.DisposeAsync();
|
||||
await galaxy.DisposeAsync();
|
||||
|
||||
// A disposed channel throws ObjectDisposedException when a call is created.
|
||||
var invoker = channel.CreateCallInvoker();
|
||||
Exception? ex = Record.Exception(() => invoker.AsyncUnaryCall(
|
||||
new Method<byte[], byte[]>(
|
||||
MethodType.Unary,
|
||||
"s",
|
||||
"m",
|
||||
Marshallers.Create(b => b, b => b),
|
||||
Marshallers.Create(b => b, b => b)),
|
||||
null,
|
||||
new CallOptions(deadline: DateTime.UtcNow.AddMilliseconds(1)),
|
||||
[]).Dispose());
|
||||
Assert.IsNotType<ObjectDisposedException>(ex);
|
||||
}
|
||||
|
||||
/// <summary>Create over an existing channel rejects a null channel.</summary>
|
||||
[Fact]
|
||||
public void Create_WithNullChannel_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => MxGatewayClient.Create(null!, Options()));
|
||||
Assert.Throws<ArgumentNullException>(() => GalaxyRepositoryClient.Create(null!, Options()));
|
||||
}
|
||||
|
||||
/// <summary>DisposeAsync swallows a gateway failure during close and still disposes.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Session_DisposeAsync_SwallowsCloseFailure()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.CloseSessionExceptions.Enqueue(new MxGatewayException("unreachable"));
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
await session.DisposeAsync();
|
||||
|
||||
Assert.Single(transport.CloseSessionCalls);
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.CloseAsync());
|
||||
}
|
||||
|
||||
/// <summary>DisposeAsync after the client is disposed does not throw.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Session_DisposeAsync_AfterClientDisposed_DoesNotThrow()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
await client.DisposeAsync();
|
||||
|
||||
await session.DisposeAsync();
|
||||
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.CloseAsync());
|
||||
}
|
||||
|
||||
/// <summary>PingAsync sends a Ping command carrying the message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PingAsync_SendsPingCommand()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(OkReply(MxCommandKind.Ping));
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
await session.PingAsync("hello");
|
||||
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.Ping, request.Command.Kind);
|
||||
Assert.Equal("hello", request.Command.Ping.Message);
|
||||
Assert.Equal("session-fixture", request.SessionId);
|
||||
}
|
||||
|
||||
/// <summary>GetSessionStateAsync returns the typed state.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetSessionStateAsync_ReturnsState()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
MxCommandReply reply = OkReply(MxCommandKind.GetSessionState);
|
||||
reply.SessionState = new SessionStateReply { State = SessionState.Ready };
|
||||
transport.AddInvokeReply(reply);
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
SessionState state = await session.GetSessionStateAsync();
|
||||
|
||||
Assert.Equal(SessionState.Ready, state);
|
||||
Assert.Equal(MxCommandKind.GetSessionState, Assert.Single(transport.InvokeCalls).Request.Command.Kind);
|
||||
}
|
||||
|
||||
/// <summary>GetSessionStateAsync throws on a reply without payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetSessionStateAsync_MissingPayload_ThrowsMalformed()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(OkReply(MxCommandKind.GetSessionState));
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
await Assert.ThrowsAsync<MxGatewayMalformedReplyException>(() => session.GetSessionStateAsync());
|
||||
}
|
||||
|
||||
/// <summary>GetWorkerInfoAsync returns the worker info payload.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetWorkerInfoAsync_ReturnsWorkerInfo()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
MxCommandReply reply = OkReply(MxCommandKind.GetWorkerInfo);
|
||||
reply.WorkerInfo = new WorkerInfoReply { WorkerProcessId = 42, WorkerVersion = "1.2.3" };
|
||||
transport.AddInvokeReply(reply);
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
WorkerInfoReply info = await session.GetWorkerInfoAsync();
|
||||
|
||||
Assert.Equal(42, info.WorkerProcessId);
|
||||
Assert.Equal("1.2.3", info.WorkerVersion);
|
||||
}
|
||||
|
||||
/// <summary>DrainEventsAsync sends max_events and returns the events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DrainEventsAsync_ReturnsEventsAndSendsMax()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
MxCommandReply reply = OkReply(MxCommandKind.DrainEvents);
|
||||
reply.DrainEvents = new DrainEventsReply();
|
||||
reply.DrainEvents.Events.Add(new MxEvent { SessionId = "session-fixture", WorkerSequence = 5 });
|
||||
transport.AddInvokeReply(reply);
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
IReadOnlyList<MxEvent> events = await session.DrainEventsAsync(10);
|
||||
|
||||
Assert.Equal(5UL, Assert.Single(events).WorkerSequence);
|
||||
Assert.Equal(10U, Assert.Single(transport.InvokeCalls).Request.Command.DrainEvents.MaxEvents);
|
||||
}
|
||||
|
||||
/// <summary>DrainEventsAsync treats a missing payload as an empty drain.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DrainEventsAsync_MissingPayload_ReturnsEmpty()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(OkReply(MxCommandKind.DrainEvents));
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
Assert.Empty(await session.DrainEventsAsync());
|
||||
}
|
||||
|
||||
/// <summary>AttachSession binds to an existing id without opening a session and resumes the stream.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachSession_BindsExistingIdWithoutOpening_AndResumesStream()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddEvent(new MxEvent { SessionId = "existing", WorkerSequence = 8 });
|
||||
await using MxGatewayClient client = new(transport.Options, transport);
|
||||
|
||||
MxGatewaySession session = client.AttachSession("existing");
|
||||
List<MxEvent> received = [];
|
||||
await foreach (MxEvent e in session.StreamEventsAsync(afterWorkerSequence: 7))
|
||||
{
|
||||
received.Add(e);
|
||||
}
|
||||
|
||||
Assert.Empty(transport.OpenSessionCalls);
|
||||
Assert.Equal("existing", session.SessionId);
|
||||
StreamEventsRequest request = Assert.Single(transport.StreamEventsCalls).Request;
|
||||
Assert.Equal("existing", request.SessionId);
|
||||
Assert.Equal(7UL, request.AfterWorkerSequence);
|
||||
Assert.Single(received);
|
||||
}
|
||||
|
||||
/// <summary>AttachSession rejects blank ids and a disposed client.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AttachSession_InvalidArguments_Throw()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
MxGatewayClient client = new(transport.Options, transport);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => client.AttachSession(" "));
|
||||
await client.DisposeAsync();
|
||||
Assert.Throws<ObjectDisposedException>(() => client.AttachSession("x"));
|
||||
}
|
||||
|
||||
private static MxCommandReply OkReply(MxCommandKind kind) => new()
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = kind,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
};
|
||||
|
||||
private static MxGatewayClientOptions Options() => new()
|
||||
{
|
||||
Endpoint = new Uri("http://localhost:5000"),
|
||||
ApiKey = "test-api-key",
|
||||
};
|
||||
|
||||
private static FakeGatewayTransport CreateTransport() => new(Options());
|
||||
}
|
||||
@@ -228,6 +228,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
/// <see cref="MxEventStreamItem"/> with the gap populated, while normal
|
||||
/// events pass through unchanged with IsReplayGap false.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventItemsAsync_SurfacesReplayGapSentinel()
|
||||
{
|
||||
@@ -430,6 +431,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unregister builds an unregister command with the server handle.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnregisterAsync_BuildsUnregisterCommand()
|
||||
{
|
||||
@@ -451,6 +453,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AdviseSupervisoryAsync_BuildsAdviseSupervisoryCommand()
|
||||
{
|
||||
@@ -473,6 +476,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that add-buffered-item returns the item handle from the typed reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddBufferedItemAsync_BuildsCommandAndReturnsItemHandle()
|
||||
{
|
||||
@@ -498,6 +502,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that set-buffered-update-interval builds the command with the interval.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetBufferedUpdateIntervalAsync_BuildsCommandWithInterval()
|
||||
{
|
||||
@@ -520,6 +525,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that suspend builds the command and returns the reply status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SuspendAsync_BuildsCommandAndReturnsStatus()
|
||||
{
|
||||
@@ -545,6 +551,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that activate builds the command and returns the reply status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ActivateAsync_BuildsCommandAndReturnsStatus()
|
||||
{
|
||||
@@ -568,6 +575,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured builds a WriteSecured command with value and user ids.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredAsync_BuildsWriteSecuredCommand()
|
||||
{
|
||||
@@ -598,6 +606,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
/// natively. The client must surface that scripted failure (as an
|
||||
/// <see cref="MxAccessException"/>) rather than pre-validating it away.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredAsync_SurfacesNativeFailureWhenNotAuthenticated()
|
||||
{
|
||||
@@ -621,6 +630,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured2Async_BuildsWriteSecured2Command()
|
||||
{
|
||||
@@ -647,6 +657,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticate-user builds the command and returns the resolved user id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_BuildsCommandAndReturnsUserId()
|
||||
{
|
||||
@@ -675,6 +686,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
/// must never contain the credential — the error path is built only from
|
||||
/// reply-derived diagnostics, not the request payload.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_FailureDoesNotLeakCredentialInErrorMessage()
|
||||
{
|
||||
@@ -698,6 +710,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that archestra-user-to-id builds the command and returns the resolved user id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId()
|
||||
{
|
||||
|
||||
@@ -90,6 +90,41 @@ public sealed class MxGatewayClientWireTests
|
||||
server.Service.AuthorizationByMethod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A safe unary call whose first attempt runs into its deadline must still get its retry:
|
||||
/// the outer budget covers every attempt, not just one DefaultCallTimeout (#197).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SafeUnaryRetry_AfterDeadlineExceededFirstAttempt_RunsTheRetry()
|
||||
{
|
||||
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync(service =>
|
||||
service.StallFirstInvokes = 1);
|
||||
await using MxGatewayClient client = server.CreateClient(
|
||||
ApiKey,
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
new MxGatewayClientRetryOptions
|
||||
{
|
||||
MaxAttempts = 2,
|
||||
Delay = TimeSpan.FromMilliseconds(10),
|
||||
MaxDelay = TimeSpan.FromMilliseconds(10),
|
||||
UseJitter = false,
|
||||
});
|
||||
|
||||
MxCommandReply reply = await client.InvokeAsync(new MxCommandRequest
|
||||
{
|
||||
SessionId = WireFakeGatewayServer.FakeGatewayService.SessionId,
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Ping,
|
||||
Ping = new PingCommand(),
|
||||
},
|
||||
});
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(2, server.Service.InvokeCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the gateway's replay-gap sentinel survives serialization and is
|
||||
/// surfaced as a typed, non-terminal stream item.
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
/// </summary>
|
||||
public sealed class MxGatewaySecretRedactionTests
|
||||
{
|
||||
/// <summary>Test: Redact replaces every occurrence of secret.</summary>
|
||||
[Fact]
|
||||
public void Redact_ReplacesEveryOccurrenceOfSecret()
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal("pw=<redacted> retry pw=<redacted> again <redacted>", result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact scrubs both secrets when one is substring of the other.</summary>
|
||||
[Fact]
|
||||
public void Redact_ScrubsBothSecretsWhenOneIsSubstringOfTheOther()
|
||||
{
|
||||
@@ -33,6 +35,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.DoesNotContain("secret", result, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact with null secrets array returns message unchanged.</summary>
|
||||
[Fact]
|
||||
public void Redact_WithNullSecretsArray_ReturnsMessageUnchanged()
|
||||
{
|
||||
@@ -43,6 +46,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact with empty secrets array returns message unchanged.</summary>
|
||||
[Fact]
|
||||
public void Redact_WithEmptySecretsArray_ReturnsMessageUnchanged()
|
||||
{
|
||||
@@ -53,6 +57,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact ignores whitespace only secret.</summary>
|
||||
[Fact]
|
||||
public void Redact_IgnoresWhitespaceOnlySecret()
|
||||
{
|
||||
@@ -64,6 +69,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redacted preserves concrete subtype and does not chain secret bearing original.</summary>
|
||||
[Fact]
|
||||
public void Redacted_PreservesConcreteSubtypeAndDoesNotChainSecretBearingOriginal()
|
||||
{
|
||||
|
||||
+11
-6
@@ -4,15 +4,16 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the credential-scrub (CLI-40) and malformed-reply (CLI-41) contracts on the
|
||||
/// Tests for the credential-scrub and malformed-reply contracts on the
|
||||
/// credential and id-returning session helpers, driven from shared behavior fixtures.
|
||||
/// </summary>
|
||||
public sealed class MxGatewaySessionReplyContractTests
|
||||
{
|
||||
/// <summary>
|
||||
/// CLI-40: when MXAccess echoes the submitted credential back in its failure diagnostic,
|
||||
/// Verifies that when MXAccess echoes the submitted credential back in its failure diagnostic,
|
||||
/// the surfaced exception message must scrub it to the library redaction marker.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_RedactsEchoedCredentialInFailureMessage()
|
||||
{
|
||||
@@ -33,13 +34,14 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-40: the redacted exception must not leak the echoed credential through any structured
|
||||
/// Verifies that the redacted exception must not leak the echoed credential through any structured
|
||||
/// accessor either — <see cref="MxAccessException.Reply"/> (protocol message, diagnostic
|
||||
/// message, and each MXSTATUS_PROXY diagnostic text) and <see cref="MxGatewayException.Statuses"/>
|
||||
/// all carry the server-echoed credential verbatim before the fix. Both the OK+negative-HRESULT
|
||||
/// and the MXACCESS_FAILURE reply route to <see cref="MxAccessException"/>, so both must scrub.
|
||||
/// </summary>
|
||||
/// <param name="fixture">The echoed-credential reply fixture to drive.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("authenticate-user.echoed-credential.reply.json")]
|
||||
[InlineData("authenticate-user.echoed-credential-mxaccess-failure.reply.json")]
|
||||
@@ -71,9 +73,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-41: an OK reply that carries neither the typed AuthenticateUser payload nor an
|
||||
/// Verifies that an OK reply that carries neither the typed AuthenticateUser payload nor an
|
||||
/// int32 return_value is a malformed reply, surfaced as a typed exception rather than an NRE.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply()
|
||||
{
|
||||
@@ -87,9 +90,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-41: an OK reply that omits the typed payload but carries an int32 return_value
|
||||
/// Verifies that an OK reply that omits the typed payload but carries an int32 return_value
|
||||
/// resolves to that return value.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_ReturnValueOnly_ResolvesReturnValue()
|
||||
{
|
||||
@@ -104,9 +108,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-41: the AddBufferedItem fallback shares the malformed-reply contract — an OK reply
|
||||
/// Verifies that the AddBufferedItem fallback shares the malformed-reply contract — an OK reply
|
||||
/// with neither a typed item handle nor an int32 return_value throws the typed exception.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddBufferedItemAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply()
|
||||
{
|
||||
|
||||
@@ -32,6 +32,9 @@ public sealed class MxStatusProxyExtensionsTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the raw success member never overrides the authoritative category.</summary>
|
||||
/// <param name="category">The status category under test.</param>
|
||||
/// <param name="success">The raw success member, which must not influence the result.</param>
|
||||
/// <param name="expected">The expected <c>IsSuccess</c> result.</param>
|
||||
[Theory]
|
||||
[InlineData(MxStatusCategory.Ok, 0, true)]
|
||||
[InlineData(MxStatusCategory.Ok, 1, true)]
|
||||
|
||||
@@ -88,15 +88,30 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
|
||||
/// <param name="apiKey">API key the client should present.</param>
|
||||
/// <returns>A client that talks to this server over h2c.</returns>
|
||||
public MxGatewayClient CreateClient(string apiKey) =>
|
||||
CreateClient(apiKey, TimeSpan.FromSeconds(30), new MxGatewayClientRetryOptions());
|
||||
|
||||
/// <summary>
|
||||
/// Creates a client bound to this server's endpoint with an explicit call timeout and retry policy.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">API key the client should present.</param>
|
||||
/// <param name="defaultCallTimeout">Per-attempt unary deadline.</param>
|
||||
/// <param name="retry">Retry options for safe unary calls.</param>
|
||||
/// <returns>A client that talks to this server over h2c.</returns>
|
||||
public MxGatewayClient CreateClient(
|
||||
string apiKey,
|
||||
TimeSpan defaultCallTimeout,
|
||||
MxGatewayClientRetryOptions retry) =>
|
||||
MxGatewayClient.Create(new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = Endpoint,
|
||||
ApiKey = apiKey,
|
||||
UseTls = false,
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(30),
|
||||
DefaultCallTimeout = defaultCallTimeout,
|
||||
Retry = retry,
|
||||
});
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Stops and disposes the underlying web application.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _app.StopAsync().ConfigureAwait(false);
|
||||
@@ -144,6 +159,17 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
|
||||
/// </summary>
|
||||
public ReplayGap? ReplayGap { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how many leading <c>Invoke</c> calls stall until the client's deadline
|
||||
/// expires, so the client sees <see cref="StatusCode.DeadlineExceeded"/> for them.
|
||||
/// </summary>
|
||||
public int StallFirstInvokes { get; set; }
|
||||
|
||||
/// <summary>Gets the number of <c>Invoke</c> calls the server received.</summary>
|
||||
public int InvokeCount => Volatile.Read(ref _invokeCount);
|
||||
|
||||
private int _invokeCount;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last <c>Invoke</c> request the client sent, as decoded from the wire.
|
||||
/// </summary>
|
||||
@@ -178,17 +204,32 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<MxCommandReply> Invoke(MxCommandRequest request, ServerCallContext context)
|
||||
public override async Task<MxCommandReply> Invoke(MxCommandRequest request, ServerCallContext context)
|
||||
{
|
||||
Record(context);
|
||||
InvokeRequest = request;
|
||||
|
||||
if (Interlocked.Increment(ref _invokeCount) <= StallFirstInvokes)
|
||||
{
|
||||
// Hold the call past the client's deadline; the cancellation is the deadline
|
||||
// firing (or the client giving up), either way the caller has stopped waiting.
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
throw new RpcException(new Status(StatusCode.DeadlineExceeded, "stalled attempt"));
|
||||
}
|
||||
|
||||
if (DenyInvoke)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.PermissionDenied, "invoke scope required"));
|
||||
}
|
||||
|
||||
return Task.FromResult(new MxCommandReply
|
||||
return new MxCommandReply
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
@@ -196,7 +237,7 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
|
||||
ProtocolStatus = Ok(),
|
||||
Hresult = 0,
|
||||
Register = new RegisterReply { ServerHandle = ServerHandle },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
private const int BrowseChildrenPageSize = 500;
|
||||
|
||||
private readonly GrpcChannel? _channel;
|
||||
private readonly bool _ownsChannel;
|
||||
private readonly IGalaxyRepositoryClientTransport _transport;
|
||||
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
||||
private bool _disposed;
|
||||
@@ -48,9 +49,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
|
||||
private GalaxyRepositoryClient(
|
||||
GrpcChannel channel,
|
||||
IGalaxyRepositoryClientTransport transport)
|
||||
IGalaxyRepositoryClientTransport transport,
|
||||
bool ownsChannel)
|
||||
{
|
||||
_channel = channel;
|
||||
_ownsChannel = ownsChannel;
|
||||
_transport = transport;
|
||||
Options = transport.Options;
|
||||
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
|
||||
@@ -95,7 +98,32 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
channel,
|
||||
new GrpcGalaxyRepositoryClientTransport(
|
||||
options,
|
||||
new GalaxyRepository.GalaxyRepositoryClient(channel)));
|
||||
new GalaxyRepository.GalaxyRepositoryClient(channel)),
|
||||
ownsChannel: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Galaxy Repository client over a caller-owned <see cref="GrpcChannel"/>.
|
||||
/// The client does not dispose the channel; the caller controls its lifetime and any
|
||||
/// TLS/handler configuration.
|
||||
/// </summary>
|
||||
/// <param name="channel">An existing channel, shared or pre-configured by the caller.</param>
|
||||
/// <param name="options">Client options (API key, timeouts, retry).</param>
|
||||
/// <returns>A new client instance that does not own the channel.</returns>
|
||||
public static GalaxyRepositoryClient Create(
|
||||
GrpcChannel channel,
|
||||
MxGatewayClientOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.Validate();
|
||||
|
||||
return new GalaxyRepositoryClient(
|
||||
channel,
|
||||
new GrpcGalaxyRepositoryClientTransport(
|
||||
options,
|
||||
new GalaxyRepository.GalaxyRepositoryClient(channel)),
|
||||
ownsChannel: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -436,7 +464,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_channel?.Dispose();
|
||||
if (_ownsChannel)
|
||||
{
|
||||
_channel?.Dispose();
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -485,8 +517,15 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
Func<CancellationToken, Task<T>> call,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Each attempt carries its own DefaultCallTimeout deadline, so the outer cap must cover
|
||||
// every attempt plus the backoff between them; capping it at one DefaultCallTimeout let a
|
||||
// DeadlineExceeded first attempt spend the whole budget and the retry never ran (#197).
|
||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(Options.DefaultCallTimeout);
|
||||
TimeSpan? budget = MxGatewayClientRetryPolicy.SafeUnaryBudget(Options);
|
||||
if (budget is not null)
|
||||
{
|
||||
timeout.CancelAfter(budget.Value);
|
||||
}
|
||||
|
||||
return await _safeUnaryRetryPipeline.ExecuteAsync(
|
||||
async token => await call(token).ConfigureAwait(false),
|
||||
|
||||
@@ -14,7 +14,8 @@ namespace ZB.MOM.WW.MxGateway.Client;
|
||||
/// </summary>
|
||||
public sealed class MxGatewayClient : IAsyncDisposable
|
||||
{
|
||||
private readonly GrpcChannel _channel;
|
||||
private readonly GrpcChannel? _channel;
|
||||
private readonly bool _ownsChannel;
|
||||
private readonly IMxGatewayClientTransport _transport;
|
||||
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
||||
private bool _disposed;
|
||||
@@ -36,14 +37,16 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
|
||||
options.Retry,
|
||||
options.LoggerFactory?.CreateLogger<MxGatewayClient>());
|
||||
_channel = null!;
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
private MxGatewayClient(
|
||||
GrpcChannel channel,
|
||||
IMxGatewayClientTransport transport)
|
||||
IMxGatewayClientTransport transport,
|
||||
bool ownsChannel)
|
||||
{
|
||||
_channel = channel;
|
||||
_ownsChannel = ownsChannel;
|
||||
_transport = transport;
|
||||
Options = transport.Options;
|
||||
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
|
||||
@@ -88,7 +91,58 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
channel,
|
||||
new GrpcMxGatewayClientTransport(
|
||||
options,
|
||||
new MxAccessGateway.MxAccessGatewayClient(channel)));
|
||||
new MxAccessGateway.MxAccessGatewayClient(channel)),
|
||||
ownsChannel: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a gateway client over a caller-owned <see cref="GrpcChannel"/>. The client
|
||||
/// does not dispose the channel; the caller controls its lifetime and any TLS/handler
|
||||
/// configuration. <see cref="MxGatewayClientOptions.Endpoint"/> is still validated but
|
||||
/// the channel's own address is used for transport.
|
||||
/// </summary>
|
||||
/// <param name="channel">An existing channel, shared or pre-configured by the caller.</param>
|
||||
/// <param name="options">Client configuration options (API key, timeouts, retry).</param>
|
||||
/// <returns>A new gateway client instance that does not own the channel.</returns>
|
||||
public static MxGatewayClient Create(
|
||||
GrpcChannel channel,
|
||||
MxGatewayClientOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.Validate();
|
||||
|
||||
return new MxGatewayClient(
|
||||
channel,
|
||||
new GrpcMxGatewayClientTransport(
|
||||
options,
|
||||
new MxAccessGateway.MxAccessGatewayClient(channel)),
|
||||
ownsChannel: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds a session object to an existing gateway session id without calling
|
||||
/// OpenSession, so a reconnecting process can resume
|
||||
/// <see cref="MxGatewaySession.StreamEventsAsync"/> with <c>afterWorkerSequence</c>.
|
||||
/// The gateway only allows the API key that opened the session to stream its events
|
||||
/// or invoke commands on it; any other key is rejected with PermissionDenied. The
|
||||
/// returned session's <see cref="MxGatewaySession.OpenSessionReply"/> carries only the
|
||||
/// session id.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The id of a session previously opened with this client's API key.</param>
|
||||
/// <returns>A session bound to <paramref name="sessionId"/>.</returns>
|
||||
public MxGatewaySession AttachSession(string sessionId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
ThrowIfDisposed();
|
||||
|
||||
return new MxGatewaySession(
|
||||
this,
|
||||
new OpenSessionReply
|
||||
{
|
||||
SessionId = sessionId,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -183,24 +237,34 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an active MXAccess alarm condition through the gateway. The
|
||||
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
|
||||
/// scope and forwards the acknowledge to the worker's MXAccess session;
|
||||
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
|
||||
/// Acknowledges an alarm as the operator. The gateway authenticates
|
||||
/// <see cref="AcknowledgeAlarmRequest.OperatorUser"/> with
|
||||
/// <see cref="AcknowledgeAlarmRequest.OperatorPassword"/> and writes the
|
||||
/// comment to the alarm's AckMsg attribute, so the alarm history records
|
||||
/// the operator. The password is redacted from any surfaced error.
|
||||
/// </summary>
|
||||
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
||||
/// <param name="request">The acknowledge request: alarm reference, comment, operator credentials.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ThrowIfDisposed();
|
||||
|
||||
return ExecuteSafeUnaryAsync(
|
||||
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
|
||||
cancellationToken);
|
||||
// Deliberately not wrapped in the retry pipeline: an acknowledge is a
|
||||
// non-idempotent operator write that re-sends the operator password and
|
||||
// can double-ack in the Historian.
|
||||
try
|
||||
{
|
||||
return await _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0)
|
||||
{
|
||||
throw MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -258,7 +322,11 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_channel?.Dispose();
|
||||
if (_ownsChannel)
|
||||
{
|
||||
_channel?.Dispose();
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -307,8 +375,15 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
Func<CancellationToken, Task<T>> call,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Each attempt carries its own DefaultCallTimeout deadline, so the outer cap must cover
|
||||
// every attempt plus the backoff between them; capping it at one DefaultCallTimeout let a
|
||||
// DeadlineExceeded first attempt spend the whole budget and the retry never ran (#197).
|
||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(Options.DefaultCallTimeout);
|
||||
TimeSpan? budget = MxGatewayClientRetryPolicy.SafeUnaryBudget(Options);
|
||||
if (budget is not null)
|
||||
{
|
||||
timeout.CancelAfter(budget.Value);
|
||||
}
|
||||
|
||||
return await _safeUnaryRetryPipeline.ExecuteAsync(
|
||||
async token => await call(token).ConfigureAwait(false),
|
||||
|
||||
@@ -41,6 +41,29 @@ internal static class MxGatewayClientRetryPolicy
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the overall budget for one retried safe unary call: every attempt's
|
||||
/// <see cref="MxGatewayClientOptions.DefaultCallTimeout"/> deadline plus the longest backoff
|
||||
/// between attempts. Returns <see langword="null"/> when the budget is too large for a
|
||||
/// cancellation timer, in which case the per-attempt deadlines and the bounded attempt count
|
||||
/// already bound the call.
|
||||
/// </summary>
|
||||
/// <param name="options">The client options supplying the call timeout and retry settings.</param>
|
||||
/// <returns>The overall budget, or <see langword="null"/> for no outer cap.</returns>
|
||||
public static TimeSpan? SafeUnaryBudget(MxGatewayClientOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
int attempts = Math.Max(1, options.Retry.MaxAttempts);
|
||||
double milliseconds = (options.DefaultCallTimeout.TotalMilliseconds * attempts)
|
||||
+ (options.Retry.MaxDelay.TotalMilliseconds * (attempts - 1));
|
||||
|
||||
// CancellationTokenSource.CancelAfter rejects anything above uint.MaxValue - 1 ms.
|
||||
return milliseconds >= uint.MaxValue - 1
|
||||
? null
|
||||
: TimeSpan.FromMilliseconds(milliseconds);
|
||||
}
|
||||
|
||||
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
|
||||
/// <param name="kind">The command kind to check.</param>
|
||||
/// <returns><see langword="true"/> if the command kind may be automatically retried; otherwise, <see langword="false"/>.</returns>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client;
|
||||
@@ -640,7 +641,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="tagAddresses">Tag addresses to read (one per result).</param>
|
||||
/// <param name="timeout">Per-call timeout for the snapshot lifecycle path; <see cref="TimeSpan.Zero"/> uses the gateway default.</param>
|
||||
/// <param name="timeout">One shared snapshot deadline for the whole command, not a per-tag budget: the worker advises every uncached tag, waits once for all of them, then tears them down. <see cref="TimeSpan.Zero"/> uses the worker default (1000 ms).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>One <see cref="BulkReadResult"/> per requested tag, in request order.</returns>
|
||||
public async Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
|
||||
@@ -854,6 +855,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task UnregisterAsync(
|
||||
int serverHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -888,6 +890,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task AdviseSupervisoryAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -985,6 +988,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task SetBufferedUpdateIntervalAsync(
|
||||
int serverHandle,
|
||||
int updateIntervalMilliseconds,
|
||||
@@ -1125,6 +1129,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WriteSecuredAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -1204,6 +1209,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WriteSecured2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -1447,6 +1453,106 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
return _client.InvokeAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a PING diagnostic command to the worker and throws if the reply is a failure.
|
||||
/// </summary>
|
||||
/// <param name="message">Optional message echoed to the worker; defaults to empty.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that completes when the worker acknowledges the ping.</returns>
|
||||
public async Task PingAsync(
|
||||
string? message = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Ping,
|
||||
Ping = new PingCommand { Message = message ?? string.Empty },
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the gateway's lifecycle state for this session.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The current session state.</returns>
|
||||
public async Task<SessionState> GetSessionStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.GetSessionState,
|
||||
GetSessionState = new GetSessionStateCommand(),
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
|
||||
return reply.SessionState?.State
|
||||
?? throw CreateMalformed(reply, "GetSessionState");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets diagnostic information about the worker process serving this session.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The worker process id, version, and MXAccess COM identifiers.</returns>
|
||||
public async Task<WorkerInfoReply> GetWorkerInfoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.GetWorkerInfo,
|
||||
GetWorkerInfo = new GetWorkerInfoCommand(),
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
|
||||
return reply.WorkerInfo
|
||||
?? throw CreateMalformed(reply, "GetWorkerInfo");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains events queued in the worker through the diagnostic command path. This removes
|
||||
/// events from the worker queue; it is not a substitute for <see cref="StreamEventsAsync"/>
|
||||
/// and is not retried automatically. The reply is bounded by server-side caps, so call
|
||||
/// repeatedly until it returns an empty list.
|
||||
/// </summary>
|
||||
/// <param name="maxEvents">Maximum events to return; 0 lets the server choose.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The drained events, possibly empty.</returns>
|
||||
public async Task<IReadOnlyList<MxEvent>> DrainEventsAsync(
|
||||
uint maxEvents = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.DrainEvents,
|
||||
DrainEvents = new DrainEventsCommand { MaxEvents = maxEvents },
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
|
||||
return reply.DrainEvents?.Events.ToArray() ?? [];
|
||||
}
|
||||
|
||||
private static MxGatewayMalformedReplyException CreateMalformed(MxCommandReply reply, string operation)
|
||||
{
|
||||
return new MxGatewayMalformedReplyException(
|
||||
$"{operation} returned a malformed reply: OK reply carried no typed payload",
|
||||
reply.SessionId,
|
||||
reply.CorrelationId,
|
||||
reply.ProtocolStatus,
|
||||
reply.HasHresult ? reply.Hresult : null,
|
||||
reply.Statuses.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams events from the worker for this session, optionally starting after a given sequence number.
|
||||
/// </summary>
|
||||
@@ -1500,8 +1606,20 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await CloseAsync().ConfigureAwait(false);
|
||||
_closeLock.Dispose();
|
||||
try
|
||||
{
|
||||
await CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is MxGatewayException or ObjectDisposedException or RpcException)
|
||||
{
|
||||
// Best-effort close: the gateway may be unreachable or the owning client
|
||||
// already disposed. Disposal must not throw or leak the lock; the gateway
|
||||
// reaps the session on its own idle/detach timers.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_closeLock.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private Task<MxCommandReply> InvokeCommandAsync(
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
|
||||
<Version>0.2.0</Version>
|
||||
<Version>0.3.0</Version>
|
||||
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<!-- Only the shipped library generates XML docs (matching src/Contracts). The Cli and
|
||||
|
||||
@@ -146,7 +146,10 @@ type MxAccessError struct { ... }
|
||||
```
|
||||
|
||||
Use `errors.Is` / `errors.As` support. Preserve raw protobuf replies on command
|
||||
errors.
|
||||
errors. `GatewayError` matches the sentinels `ErrUnauthenticated` and
|
||||
`ErrPermissionDenied` through `errors.Is` by gRPC status code, so callers treat
|
||||
the two authorization failures distinctly (`docs/ClientLibrariesDesign.md`)
|
||||
without parsing messages.
|
||||
|
||||
## Test CLI
|
||||
|
||||
|
||||
+64
-5
@@ -92,7 +92,13 @@ returned subscription owns cancellation and exposes `Close` for deterministic
|
||||
goroutine cleanup. Raw protobuf messages remain available through the
|
||||
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
|
||||
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
||||
errors preserve the raw reply.
|
||||
errors preserve the raw reply. Authorization failures are distinct:
|
||||
`errors.Is(err, mxgateway.ErrUnauthenticated)` matches a rejected API key
|
||||
(gRPC `Unauthenticated` — fix the key) and `errors.Is(err,
|
||||
mxgateway.ErrPermissionDenied)` matches a valid key missing a scope or another
|
||||
key's session (gRPC `PermissionDenied` — grant the scope). `GatewayError.Code()`
|
||||
returns the raw gRPC status code. The match reads only the status code, so it
|
||||
works through the credential-redaction wrapper.
|
||||
|
||||
`EnsureMxAccessSuccess` follows COM semantics: only a **negative** HRESULT is a
|
||||
failure, so positive success codes such as `S_FALSE` (1) pass. `StatusSucceeded`
|
||||
@@ -100,6 +106,29 @@ judges each `MXSTATUS_PROXY` entry by its category — an entry fails when
|
||||
`Category` is not `MX_STATUS_CATEGORY_OK`, and the raw `Success` member is a
|
||||
diagnostic that never decides the verdict. A nil entry is success.
|
||||
|
||||
`Dial` is connect-or-fail: it creates the connection with `grpc.NewClient`
|
||||
(the endpoint resolves with the `passthrough` scheme unless it names one) and
|
||||
then waits for the channel to become READY, bounded by `DialTimeout` (default
|
||||
10 s) or the context deadline. A failure is returned as a `GatewayError` with
|
||||
`Op == "dial"`. Passing a connection to `NewClient`/`NewGalaxyClient` stays lazy.
|
||||
|
||||
### Event buffering
|
||||
|
||||
The channel returned by `Session.Events`/`EventsAfter` buffers
|
||||
`Options.EventBufferSize` data results (default `DefaultEventBufferSize`, 1024).
|
||||
If the consumer falls behind and the buffer fills, the stream is cancelled; the
|
||||
results already buffered are still delivered in order, followed by a final
|
||||
result whose error matches `errors.Is(err, mxgateway.ErrSlowConsumer)`. Raise
|
||||
`EventBufferSize` for bursty feeds, or use `SubscribeEvents`/`SubscribeEventsAfter`,
|
||||
which block instead of dropping and are the non-dropping alternative.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
`Session.Ping`, `GetSessionState`, `GetWorkerInfo`, and `DrainEvents` are typed
|
||||
wrappers over the diagnostic commands (`PingRaw` remains for the raw reply).
|
||||
`DrainEvents(ctx, maxEvents)` returns fewer events than requested when the
|
||||
server caps the reply; call it until it returns an empty slice.
|
||||
|
||||
### Reconnect-replay gap
|
||||
|
||||
Each `EventResult` carries exactly one of `Event`, `ReplayGap`, or `Err`. When
|
||||
@@ -140,11 +169,37 @@ sentinel typed and observable; it never synthesizes or swallows it.
|
||||
|
||||
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
|
||||
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
|
||||
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
|
||||
`Client.AcknowledgeAlarm` to ack an alarm by full reference. Acks carry the
|
||||
operator's Galaxy credentials (`OperatorUser` required, `OperatorPassword`,
|
||||
optional `OperatorDomain`) so the gateway authenticates as the operator and the
|
||||
Historian records that operator; an empty password is allowed, and the password
|
||||
is scrubbed from any returned error. The operator's full name is not sent by the client and is not propagated to MXAccess (`operator_full_name` on the worker commands is accepted but ignored); the Historian takes it from the authenticated Galaxy account. The streaming
|
||||
call returns a `StreamAlarmsClient`; cancel its context to terminate the
|
||||
stream. All three pass straight through to the gateway's central alarm
|
||||
monitor.
|
||||
|
||||
`ActiveAlarmSnapshot.GetFromTruncatedSnapshot()` reports that the record came
|
||||
from a provider fetch which hit the per-fetch cap: the snapshot set may omit
|
||||
active alarms, and the gateway suspended its absence-implies-cleared inference
|
||||
for that poll. Treat the set as possibly incomplete rather than reconciling
|
||||
deletions from it. It is set-level degraded status, not a comment on the
|
||||
record's own fidelity, and is distinct from `Degraded` (the subtag fallback
|
||||
provider).
|
||||
|
||||
`StreamAlarms` also carries that completeness verdict at feed level, as a frame
|
||||
whose `GetSnapshotStatus()` is non-nil and whose `GetTruncated()` is true while
|
||||
the monitor's cached set derives from a truncated fetch. One arrives at stream
|
||||
open (after the `GetProviderStatus()` frame, before the cached
|
||||
`GetActiveAlarm()` frames) so a late joiner learns the current verdict, then one
|
||||
on every verdict change — including the clearing frame sent when the gateway's
|
||||
alarm monitor restarts and drops a truncated verdict. Track it if you need set
|
||||
completeness on a live feed without polling `QueryActiveAlarms`.
|
||||
|
||||
The CLI's text rows always spell out `degraded=false` and `truncated=false`.
|
||||
Its protobuf-JSON mode retains the standard proto3 omission rule, so those
|
||||
false-valued payloads appear as `{"providerStatus":{}}` and
|
||||
`{"snapshotStatus":{}}`; absent means false.
|
||||
|
||||
## Write Semantics And Common Pitfalls
|
||||
|
||||
These are MXAccess parity behaviors that surprise new callers. The gateway
|
||||
@@ -377,7 +432,11 @@ the gateway:
|
||||
|
||||
Every subcommand wired into the CLI. All accept the common flags
|
||||
(`-endpoint`, `-plaintext`, `-api-key` / `-api-key-env`, `-ca-cert`,
|
||||
`-server-name-override`, `-call-timeout`) and most accept `-json`.
|
||||
`-server-name-override`, `-call-timeout`, `-require-certificate-validation`) and
|
||||
most accept `-json`. Over TLS without `-ca-cert` the CLI accepts any gateway
|
||||
certificate (the self-signed default); `-require-certificate-validation` sets
|
||||
`Options.RequireCertificateValidation` so the certificate must chain to the
|
||||
system trust roots instead.
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
@@ -402,7 +461,7 @@ Every subcommand wired into the CLI. All accept the common flags
|
||||
| `bench-read-bulk` | Throughput benchmark (`-duration-seconds`, `-warmup-seconds`, `-bulk-size`). |
|
||||
| `stream-events` | Stream item-value events for a session (`-session-id`, `-limit`). |
|
||||
| `stream-alarms` | Stream the alarm feed (`-filter-prefix`, `-limit`). |
|
||||
| `acknowledge-alarm` | Acknowledge an alarm reference. |
|
||||
| `acknowledge-alarm` | Acknowledge an alarm as an operator (`-operator`, `-operator-domain`, `-operator-password-env`, default `MXGATEWAY_OPERATOR_PASSWORD`). |
|
||||
| `smoke` | End-to-end smoke workflow against one item. |
|
||||
| `galaxy-test-connection` | Probe the Galaxy Repository RPC connection. |
|
||||
| `galaxy-last-deploy` | Print the most recent deploy event. |
|
||||
@@ -422,7 +481,7 @@ go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -typ
|
||||
go run ./cmd/mxgw-go write-bulk -session-id <id> -server-handle 1 -item-handles 1,2 -values 10,20 -type int32 -plaintext -json
|
||||
go run ./cmd/mxgw-go read-bulk -session-id <id> -item-handles 1,2 -plaintext -json
|
||||
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
|
||||
go run ./cmd/mxgw-go stream-alarms -plaintext -json
|
||||
go run ./cmd/mxgw-go stream-alarms -plaintext -limit 4 -json
|
||||
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
|
||||
go run ./cmd/mxgw-go galaxy-test-connection -plaintext -json
|
||||
go run ./cmd/mxgw-go galaxy-last-deploy -plaintext -json
|
||||
|
||||
@@ -40,6 +40,10 @@ type commonOptions struct {
|
||||
CACertFile string `json:"caCertFile,omitempty"`
|
||||
ServerName string `json:"serverNameOverride,omitempty"`
|
||||
CallTimeout string `json:"callTimeout,omitempty"`
|
||||
// RequireCertificateValidation verifies the gateway certificate against the
|
||||
// system trust roots when no -ca-cert is pinned, instead of the lenient
|
||||
// self-signed default.
|
||||
RequireCertificateValidation bool `json:"requireCertificateValidation,omitempty"`
|
||||
|
||||
apiKeyValue string
|
||||
timeout time.Duration
|
||||
@@ -571,7 +575,7 @@ func runReadBulk(ctx context.Context, args []string, stdout, stderr io.Writer) e
|
||||
sessionID := flags.String("session-id", "", "gateway session id")
|
||||
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||
items := flags.String("items", "", "comma-separated tag addresses")
|
||||
timeoutMs := flags.Int("timeout-ms", 0, "per-tag snapshot timeout in milliseconds (0 = worker default)")
|
||||
timeoutMs := flags.Int("timeout-ms", 0, "snapshot deadline in milliseconds, shared by all tags in the command (0 = worker default, 1000 ms)")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -754,7 +758,7 @@ func runBenchReadBulk(ctx context.Context, args []string, stdout, stderr io.Writ
|
||||
tagStart := flags.Int("tag-start", 1, "first machine number")
|
||||
tagPrefix := flags.String("tag-prefix", "TestMachine_", "tag prefix (machine number appended as %03d)")
|
||||
tagAttribute := flags.String("tag-attribute", "TestChangingInt", "attribute appended to each tag prefix")
|
||||
timeoutMs := flags.Int("timeout-ms", 1500, "per-tag snapshot timeout in milliseconds")
|
||||
timeoutMs := flags.Int("timeout-ms", 1500, "snapshot deadline in milliseconds, shared by all tags in each ReadBulk command")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -1097,7 +1101,8 @@ func runStreamAlarms(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
|
||||
// formatAlarmFeedMessage renders one AlarmFeedMessage in the CLI's plain-text
|
||||
// output style, distinguishing the active-alarm snapshot, snapshot-complete
|
||||
// sentinel, and transition cases of the message's payload oneof.
|
||||
// sentinel, transition, provider-status, and snapshot-status cases of the
|
||||
// message's payload oneof.
|
||||
func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
|
||||
switch {
|
||||
case message.GetActiveAlarm() != nil:
|
||||
@@ -1108,11 +1113,20 @@ func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
|
||||
case message.GetTransition() != nil:
|
||||
transition := message.GetTransition()
|
||||
return fmt.Sprintf("transition %s kind=%s severity=%d", transition.GetAlarmFullReference(), transition.GetTransitionKind(), transition.GetSeverity())
|
||||
case message.GetProviderStatus() != nil:
|
||||
status := message.GetProviderStatus()
|
||||
return fmt.Sprintf("provider-status mode=%s degraded=%t reason=%q", status.GetMode(), status.GetDegraded(), status.GetReason())
|
||||
case message.GetSnapshotStatus() != nil:
|
||||
return fmt.Sprintf("snapshot-status truncated=%t", message.GetSnapshotStatus().GetTruncated())
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// defaultOperatorPasswordEnv is the CLI environment variable holding the
|
||||
// operator password for acknowledge-alarm.
|
||||
const defaultOperatorPasswordEnv = "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
|
||||
func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -1120,7 +1134,12 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
reference := flags.String("reference", "", "full alarm reference to acknowledge")
|
||||
comment := flags.String("comment", "", "operator acknowledge comment")
|
||||
operator := flags.String("operator", "", "operator user performing the acknowledge")
|
||||
operator := flags.String("operator", "", "operator user performing the acknowledge (required)")
|
||||
operatorDomain := flags.String("operator-domain", "", "operator Galaxy domain (optional)")
|
||||
// Prefer the environment variable so the credential stays out of shell
|
||||
// history and the process table; an empty password is allowed.
|
||||
operatorPassword := flags.String("operator-password", "", "operator password (prefer -operator-password-env)")
|
||||
operatorPasswordEnv := flags.String("operator-password-env", defaultOperatorPasswordEnv, "environment variable containing the operator password")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -1128,6 +1147,17 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
if *reference == "" {
|
||||
return errors.New("reference is required")
|
||||
}
|
||||
if *operator == "" {
|
||||
return errors.New("operator is required")
|
||||
}
|
||||
resolvedOperatorPassword := *operatorPassword
|
||||
if resolvedOperatorPassword == "" {
|
||||
envName := *operatorPasswordEnv
|
||||
if envName == "" {
|
||||
envName = defaultOperatorPasswordEnv
|
||||
}
|
||||
resolvedOperatorPassword = os.Getenv(envName)
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
@@ -1139,6 +1169,8 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
AlarmFullReference: *reference,
|
||||
Comment: *comment,
|
||||
OperatorUser: *operator,
|
||||
OperatorDomain: *operatorDomain,
|
||||
OperatorPassword: resolvedOperatorPassword,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1269,6 +1301,8 @@ func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
|
||||
flags.StringVar(&common.CACertFile, "ca-cert", "", "CA certificate file")
|
||||
flags.StringVar(&common.ServerName, "server-name-override", "", "TLS server name override")
|
||||
flags.StringVar(&common.CallTimeout, "call-timeout", "30s", "per-call timeout")
|
||||
flags.BoolVar(&common.RequireCertificateValidation, "require-certificate-validation", false,
|
||||
"verify the gateway TLS certificate against the system trust roots when no -ca-cert is set (default: accept any certificate)")
|
||||
return common
|
||||
}
|
||||
|
||||
@@ -1279,12 +1313,13 @@ func dialForCommand(ctx context.Context, common *commonOptions) (*mxgateway.Clie
|
||||
}
|
||||
|
||||
client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
||||
Endpoint: options.Endpoint,
|
||||
APIKey: options.apiKeyValue,
|
||||
Plaintext: options.Plaintext,
|
||||
CACertFile: options.CACertFile,
|
||||
ServerNameOverride: options.ServerName,
|
||||
CallTimeout: options.timeout,
|
||||
Endpoint: options.Endpoint,
|
||||
APIKey: options.apiKeyValue,
|
||||
Plaintext: options.Plaintext,
|
||||
CACertFile: options.CACertFile,
|
||||
ServerNameOverride: options.ServerName,
|
||||
CallTimeout: options.timeout,
|
||||
RequireCertificateValidation: options.RequireCertificateValidation,
|
||||
})
|
||||
return client, options, err
|
||||
}
|
||||
@@ -1495,12 +1530,13 @@ func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgatewa
|
||||
}
|
||||
|
||||
client, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
|
||||
Endpoint: options.Endpoint,
|
||||
APIKey: options.apiKeyValue,
|
||||
Plaintext: options.Plaintext,
|
||||
CACertFile: options.CACertFile,
|
||||
ServerNameOverride: options.ServerName,
|
||||
CallTimeout: options.timeout,
|
||||
Endpoint: options.Endpoint,
|
||||
APIKey: options.apiKeyValue,
|
||||
Plaintext: options.Plaintext,
|
||||
CACertFile: options.CACertFile,
|
||||
ServerNameOverride: options.ServerName,
|
||||
CallTimeout: options.timeout,
|
||||
RequireCertificateValidation: options.RequireCertificateValidation,
|
||||
})
|
||||
return client, options, err
|
||||
}
|
||||
|
||||
@@ -283,6 +283,68 @@ type pingFakeGateway struct {
|
||||
pb.UnimplementedMxAccessGatewayServer
|
||||
}
|
||||
|
||||
// alarmStatusFakeGateway emits both status arms with their proto3 boolean
|
||||
// defaults so the CLI contract covers values that protobuf JSON omits.
|
||||
type alarmStatusFakeGateway struct {
|
||||
pb.UnimplementedMxAccessGatewayServer
|
||||
}
|
||||
|
||||
func (g *alarmStatusFakeGateway) StreamAlarms(
|
||||
_ *pb.StreamAlarmsRequest,
|
||||
stream grpc.ServerStreamingServer[pb.AlarmFeedMessage],
|
||||
) error {
|
||||
if err := stream.Send(&pb.AlarmFeedMessage{
|
||||
Payload: &pb.AlarmFeedMessage_ProviderStatus{
|
||||
ProviderStatus: &pb.AlarmProviderStatus{Degraded: false},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return stream.Send(&pb.AlarmFeedMessage{
|
||||
Payload: &pb.AlarmFeedMessage_SnapshotStatus{
|
||||
SnapshotStatus: &pb.AlarmSnapshotStatus{Truncated: false},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func startAlarmStatusGateway(t *testing.T) string {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
server := grpc.NewServer()
|
||||
pb.RegisterMxAccessGatewayServer(server, &alarmStatusFakeGateway{})
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(func() {
|
||||
server.Stop()
|
||||
_ = listener.Close()
|
||||
})
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
func TestRunStreamAlarmsPrintsStatusArmsInTextAndProtobufJSON(t *testing.T) {
|
||||
endpoint := startAlarmStatusGateway(t)
|
||||
baseArgs := []string{"stream-alarms", "-endpoint", endpoint, "-plaintext", "-api-key", "test", "-limit", "2"}
|
||||
|
||||
var textOut, textErr bytes.Buffer
|
||||
if err := runWithIO(t.Context(), baseArgs, &textOut, &textErr); err != nil {
|
||||
t.Fatalf("text stream-alarms error = %v; stderr = %s", err, textErr.String())
|
||||
}
|
||||
if got, want := strings.TrimSpace(textOut.String()), "provider-status mode=ALARM_PROVIDER_MODE_UNSPECIFIED degraded=false reason=\"\"\nsnapshot-status truncated=false"; got != want {
|
||||
t.Fatalf("text stream-alarms output = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
var jsonOut, jsonErr bytes.Buffer
|
||||
jsonArgs := append(append([]string{}, baseArgs...), "-json")
|
||||
if err := runWithIO(t.Context(), jsonArgs, &jsonOut, &jsonErr); err != nil {
|
||||
t.Fatalf("JSON stream-alarms error = %v; stderr = %s", err, jsonErr.String())
|
||||
}
|
||||
if got, want := strings.TrimSpace(jsonOut.String()), "{\"providerStatus\":{}}\n{\"snapshotStatus\":{}}"; got != want {
|
||||
t.Fatalf("JSON stream-alarms output = %q, want protobuf JSON %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *pingFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
|
||||
echo := "pong:" + req.GetCommand().GetPing().GetMessage()
|
||||
return &pb.MxCommandReply{
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// #199 — `--tls` without a CA used to be skip-verify with no way out from the
|
||||
// CLI. -require-certificate-validation must make the CLI reject a certificate
|
||||
// the system roots do not trust (the gateway's own self-signed one).
|
||||
|
||||
type openSessionFakeGateway struct {
|
||||
pb.UnimplementedMxAccessGatewayServer
|
||||
}
|
||||
|
||||
func (g *openSessionFakeGateway) OpenSession(context.Context, *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
|
||||
return &pb.OpenSessionReply{
|
||||
SessionId: "tls-session",
|
||||
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func startSelfSignedTLSGateway(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})))
|
||||
pb.RegisterMxAccessGatewayServer(server, &openSessionFakeGateway{})
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(func() {
|
||||
server.Stop()
|
||||
_ = listener.Close()
|
||||
})
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
func TestRequireCertificateValidationFlagRejectsSelfSignedGateway(t *testing.T) {
|
||||
endpoint := startSelfSignedTLSGateway(t)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
base := []string{"open-session", "-endpoint", endpoint, "-api-key", "test", "-call-timeout", "5s"}
|
||||
|
||||
// Positive control: the lenient default accepts the self-signed certificate.
|
||||
var okOut, okErr bytes.Buffer
|
||||
if err := runWithIO(ctx, base, &okOut, &okErr); err != nil {
|
||||
t.Fatalf("default TLS open-session error = %v; stderr = %s", err, okErr.String())
|
||||
}
|
||||
if got := strings.TrimSpace(okOut.String()); got != "tls-session" {
|
||||
t.Fatalf("default TLS open-session output = %q", got)
|
||||
}
|
||||
|
||||
// Strict: the handshake never verifies, so the connected-dial wait runs out.
|
||||
// The dial error (not a flag-parse error) is what proves the flag reached the
|
||||
// TLS configuration; the positive control above proves the endpoint is live.
|
||||
strictCtx, strictCancel := context.WithTimeout(t.Context(), 2*time.Second)
|
||||
defer strictCancel()
|
||||
var strictOut, strictErr bytes.Buffer
|
||||
err := runWithIO(strictCtx, append(append([]string{}, base...), "-require-certificate-validation"), &strictOut, &strictErr)
|
||||
if err == nil {
|
||||
t.Fatalf("strict TLS open-session succeeded against a self-signed gateway; stdout = %s", strictOut.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dial failed") {
|
||||
t.Fatalf("strict TLS open-session error = %v; want a dial failure (stderr = %s)", err, strictErr.String())
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ $ErrorActionPreference = 'Stop'
|
||||
# those header stamps, so a regeneration on an off-pin machine would churn the tree and make
|
||||
# check-codegen Check 4 false-fail (or mask real drift under churn). Assert the exact versions
|
||||
# so a regen is deterministic. protoc itself is warn-only (source_code_info is normalized out of
|
||||
# the committed bindings), matching publish-client-proto-inputs.ps1.
|
||||
# the committed bindings), matching publish-client-proto-inputs.ps1. On Windows a plugin reports
|
||||
# its argv[0] name, so the banner carries a trailing `.exe` (e.g. "protoc-gen-go.exe v1.36.11");
|
||||
# Get-NormalizedToolVersion strips that suffix before the pin compare so Check 4 runs the same on
|
||||
# Windows as everywhere else.
|
||||
$PinnedProtocGenGoVersion = 'protoc-gen-go v1.36.11'
|
||||
$PinnedProtocGenGoGrpcVersion = 'protoc-gen-go-grpc 1.6.2'
|
||||
$PinnedProtocVersion = 'libprotoc 34.1'
|
||||
@@ -16,6 +19,14 @@ $protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos'
|
||||
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
|
||||
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
|
||||
|
||||
function Get-NormalizedToolVersion {
|
||||
# On Windows a plugin reports its argv[0] name, so the banner carries an `.exe`
|
||||
# suffix ("protoc-gen-go.exe v1.36.11"). Strip it so the pin compare is
|
||||
# host-independent; the version part must still match exactly.
|
||||
param([string]$RawBanner)
|
||||
return ($RawBanner -replace '\.exe(?=\s)', '')
|
||||
}
|
||||
|
||||
function Resolve-Tool {
|
||||
# Resolve a codegen tool from PATH first (portable), then the documented Windows fallbacks,
|
||||
# instead of the previous hard-coded per-machine paths. See docs/ToolchainLinks.md.
|
||||
@@ -51,17 +62,17 @@ $protocGenGoGrpc = Resolve-Tool -Names @('protoc-gen-go-grpc', 'protoc-gen-go-gr
|
||||
|
||||
# Assert the pinned plugin versions before generating so Check 4 cannot false-fail (or mask drift)
|
||||
# on an off-pin machine. protoc is warn-only.
|
||||
$protocGenGoVersion = (& $protocGenGo --version 2>&1 | Out-String).Trim()
|
||||
$protocGenGoVersion = Get-NormalizedToolVersion (& $protocGenGo --version 2>&1 | Out-String).Trim()
|
||||
if ($protocGenGoVersion -ne $PinnedProtocGenGoVersion) {
|
||||
throw "protoc-gen-go reports '$protocGenGoVersion', but regeneration is pinned to '$PinnedProtocGenGoVersion'. " +
|
||||
"Install the pin: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11"
|
||||
}
|
||||
$protocGenGoGrpcVersion = (& $protocGenGoGrpc --version 2>&1 | Out-String).Trim()
|
||||
$protocGenGoGrpcVersion = Get-NormalizedToolVersion (& $protocGenGoGrpc --version 2>&1 | Out-String).Trim()
|
||||
if ($protocGenGoGrpcVersion -ne $PinnedProtocGenGoGrpcVersion) {
|
||||
throw "protoc-gen-go-grpc reports '$protocGenGoGrpcVersion', but regeneration is pinned to '$PinnedProtocGenGoGrpcVersion'. " +
|
||||
"Install the pin: go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2"
|
||||
}
|
||||
$protocVersion = (& $protoc --version 2>&1 | Out-String).Trim()
|
||||
$protocVersion = Get-NormalizedToolVersion (& $protoc --version 2>&1 | Out-String).Trim()
|
||||
if ($protocVersion -ne $PinnedProtocVersion) {
|
||||
Write-Warning "protoc reports '$protocVersion', pin is '$PinnedProtocVersion'. Descriptor comments are normalized out of the committed Go bindings, so patch drift is tolerated; keep CI on the pin."
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,11 @@ import (
|
||||
// invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess
|
||||
// session; the resulting native MxStatus is returned in the reply.
|
||||
//
|
||||
// OperatorUser is required: the gateway authenticates the operator (with
|
||||
// OperatorPassword and optional OperatorDomain) so the Historian records the
|
||||
// real operator. An empty password is allowed. The password is scrubbed from
|
||||
// any error this method returns.
|
||||
//
|
||||
// Acks are idempotent — re-acking an already-acked condition is a no-op at
|
||||
// the MxAccess layer.
|
||||
func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
||||
@@ -22,10 +27,10 @@ func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequ
|
||||
|
||||
reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "acknowledge alarm", Err: err}
|
||||
return nil, redactSecrets(&GatewayError{Op: "acknowledge alarm", Err: err}, req.GetOperatorPassword())
|
||||
}
|
||||
if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
|
||||
return reply, err
|
||||
return reply, redactSecrets(err, req.GetOperatorPassword())
|
||||
}
|
||||
|
||||
return reply, nil
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
@@ -38,6 +39,8 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
Comment: "investigating",
|
||||
OperatorUser: "alice",
|
||||
OperatorPassword: "pw-secret",
|
||||
OperatorDomain: "CORP",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AcknowledgeAlarm() error = %v", err)
|
||||
@@ -51,11 +54,37 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||
if got := fake.acknowledgeRequest.GetComment(); got != "investigating" {
|
||||
t.Fatalf("captured comment = %q", got)
|
||||
}
|
||||
if got := fake.acknowledgeRequest.GetOperatorPassword(); got != "pw-secret" {
|
||||
t.Fatalf("captured operator password mismatch")
|
||||
}
|
||||
if got := fake.acknowledgeRequest.GetOperatorDomain(); got != "CORP" {
|
||||
t.Fatalf("captured operator domain = %q", got)
|
||||
}
|
||||
if got := fake.acknowledgeAuth; got != "Bearer test-api-key" {
|
||||
t.Fatalf("authorization metadata = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeAlarmRedactsPasswordFromError(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{
|
||||
acknowledgeError: status.Error(codes.Internal, "boom pw-secret"),
|
||||
}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
OperatorUser: "alice",
|
||||
OperatorPassword: "pw-secret",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("AcknowledgeAlarm() returned no error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "pw-secret") {
|
||||
t.Fatalf("error leaks operator password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
|
||||
@@ -15,10 +15,12 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/connectivity"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
@@ -61,11 +63,10 @@ func Dial(ctx context.Context, opts Options) (*Client, error) {
|
||||
grpc.WithTransportCredentials(transportCredentials),
|
||||
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
|
||||
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
|
||||
grpc.WithBlock(),
|
||||
}
|
||||
dialOptions = append(dialOptions, opts.DialOptions...)
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
|
||||
conn, err := dialConnected(dialCtx, opts.Endpoint, dialOptions)
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "dial", Err: err}
|
||||
}
|
||||
@@ -265,3 +266,34 @@ func (o OpenSessionOptions) Request() *OpenSessionRequest {
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// dialConnected creates a connection with grpc.NewClient and then waits for it
|
||||
// to become READY, bounded by ctx. This preserves the connect-or-fail contract
|
||||
// the removed grpc.DialContext + grpc.WithBlock combination gave Dial callers.
|
||||
// The endpoint is resolved with the passthrough scheme unless it already names a
|
||||
// scheme, matching DialContext's historical default (NewClient defaults to dns).
|
||||
func dialConnected(ctx context.Context, endpoint string, dialOptions []grpc.DialOption) (*grpc.ClientConn, error) {
|
||||
target := endpoint
|
||||
if !strings.Contains(target, "://") {
|
||||
target = "passthrough:///" + target
|
||||
}
|
||||
conn, err := grpc.NewClient(target, dialOptions...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn.Connect()
|
||||
for {
|
||||
state := conn.GetState()
|
||||
if state == connectivity.Ready {
|
||||
return conn, nil
|
||||
}
|
||||
if state == connectivity.Shutdown {
|
||||
_ = conn.Close()
|
||||
return nil, errors.New("connection shut down while connecting")
|
||||
}
|
||||
if !conn.WaitForStateChange(ctx, state) {
|
||||
_ = conn.Close()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
|
||||
streamDone: make(chan struct{}),
|
||||
streamEventCount: 64,
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
client, cleanup := newBufconnClientWithOptions(t, fake, Options{EventBufferSize: eventBufferSize})
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestEventsSlowConsumerYieldsErrSlowConsumerBeforeClose(t *testing.T) {
|
||||
streamDone: make(chan struct{}),
|
||||
streamEventCount: 64,
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
client, cleanup := newBufconnClientWithOptions(t, fake, Options{EventBufferSize: eventBufferSize})
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
@@ -209,7 +209,7 @@ func TestEventsFullBufferTerminalErrorKeepsRootCause(t *testing.T) {
|
||||
streamEventCount: eventBufferSize,
|
||||
streamTerminalErr: status.Error(codes.Internal, "boom"),
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
client, cleanup := newBufconnClientWithOptions(t, fake, Options{EventBufferSize: eventBufferSize})
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
@@ -280,7 +280,7 @@ func TestSubscribeEventsFullBufferDeliversTerminalError(t *testing.T) {
|
||||
streamEventCount: eventBufferSize + eventBufferReservedSlots,
|
||||
streamTerminalErr: status.Error(codes.Internal, "boom"),
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
client, cleanup := newBufconnClientWithOptions(t, fake, Options{EventBufferSize: eventBufferSize})
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
@@ -1047,3 +1047,240 @@ func TestWriteArrayElementsSendsWriteCommandWithSparseOneof(t *testing.T) {
|
||||
t.Errorf("element float value = %v, want 1.5", elem.GetValue().GetFloatValue())
|
||||
}
|
||||
}
|
||||
|
||||
// newBufconnClientWithOptions mirrors newBufconnClient but lets a test tune Options.
|
||||
func newBufconnClientWithOptions(t *testing.T, fake *fakeGatewayServer, opts Options) (*Client, func()) {
|
||||
t.Helper()
|
||||
listener := bufconn.Listen(bufSize)
|
||||
server := grpc.NewServer()
|
||||
pb.RegisterMxAccessGatewayServer(server, fake)
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
opts.Endpoint = "bufnet"
|
||||
opts.APIKey = "test-api-key"
|
||||
opts.Plaintext = true
|
||||
opts.DialOptions = append(opts.DialOptions, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return listener.DialContext(ctx)
|
||||
}))
|
||||
client, err := Dial(context.Background(), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial() error = %v", err)
|
||||
}
|
||||
return client, func() {
|
||||
client.Close()
|
||||
server.Stop()
|
||||
listener.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsHonorsConfiguredEventBufferSize(t *testing.T) {
|
||||
// 100 events exceed the old fixed 16-slot buffer; a 256-slot buffer must
|
||||
// hold them all without a slow-consumer termination.
|
||||
fake := &fakeGatewayServer{
|
||||
streamStarted: make(chan struct{}),
|
||||
streamDone: make(chan struct{}),
|
||||
streamEventCount: 100,
|
||||
streamTerminalErr: status.Error(codes.Internal, "boom"),
|
||||
}
|
||||
client, cleanup := newBufconnClientWithOptions(t, fake, Options{EventBufferSize: 256})
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
events, err := session.Events(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Events() error = %v", err)
|
||||
}
|
||||
<-fake.streamDone
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
count := 0
|
||||
var last EventResult
|
||||
for res := range events {
|
||||
if res.Event != nil {
|
||||
count++
|
||||
}
|
||||
last = res
|
||||
}
|
||||
if count != 100 {
|
||||
t.Fatalf("delivered events = %d, want 100", count)
|
||||
}
|
||||
if errors.Is(last.Err, ErrSlowConsumer) || status.Code(last.Err) != codes.Internal {
|
||||
t.Fatalf("terminal err = %v, want the genuine Internal error", last.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsOverflowDeliversBufferedEventsBeforeSlowConsumerError(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
streamStarted: make(chan struct{}),
|
||||
streamDone: make(chan struct{}),
|
||||
streamEventCount: 64,
|
||||
}
|
||||
client, cleanup := newBufconnClientWithOptions(t, fake, Options{EventBufferSize: 4})
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
events, err := session.Events(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Events() error = %v", err)
|
||||
}
|
||||
<-fake.streamStarted
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
buffered := 0
|
||||
var last EventResult
|
||||
for res := range events {
|
||||
if res.Event != nil {
|
||||
buffered++
|
||||
if got := res.Event.GetWorkerSequence(); got != uint64(buffered) {
|
||||
t.Fatalf("event %d has sequence %d, want in-order", buffered, got)
|
||||
}
|
||||
}
|
||||
last = res
|
||||
}
|
||||
if buffered != 4 {
|
||||
t.Fatalf("buffered events delivered = %d, want 4 (all data slots)", buffered)
|
||||
}
|
||||
if !errors.Is(last.Err, ErrSlowConsumer) {
|
||||
t.Fatalf("final err = %v, want ErrSlowConsumer", last.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventBufferSizeDefault(t *testing.T) {
|
||||
if got := (Options{}).eventBufferSize(); got != DefaultEventBufferSize {
|
||||
t.Fatalf("default = %d, want %d", got, DefaultEventBufferSize)
|
||||
}
|
||||
if got := (Options{EventBufferSize: 7}).eventBufferSize(); got != 7 {
|
||||
t.Fatalf("explicit = %d, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialFailsWhenEndpointUnreachable(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_, err := Dial(ctx, Options{
|
||||
Endpoint: "bufnet",
|
||||
Plaintext: true,
|
||||
DialTimeout: 300 * time.Millisecond,
|
||||
DialOptions: []grpc.DialOption{grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||
return nil, errors.New("refused")
|
||||
})},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Dial() error = nil, want connect failure")
|
||||
}
|
||||
var gwErr *GatewayError
|
||||
if !errors.As(err, &gwErr) || gwErr.Op != "dial" {
|
||||
t.Fatalf("Dial() error = %v, want *GatewayError Op=dial", err)
|
||||
}
|
||||
}
|
||||
|
||||
func okStatus() *pb.ProtocolStatus {
|
||||
return &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK}
|
||||
}
|
||||
|
||||
func TestSessionPingReturnsEchoedMessage(t *testing.T) {
|
||||
fake := &fakeGatewayServer{invokeReply: &pb.MxCommandReply{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_PING,
|
||||
ProtocolStatus: okStatus(),
|
||||
DiagnosticMessage: "pong:hi",
|
||||
}}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
got, err := session.Ping(context.Background(), "hi")
|
||||
if err != nil {
|
||||
t.Fatalf("Ping() error = %v", err)
|
||||
}
|
||||
if got != "pong:hi" {
|
||||
t.Fatalf("Ping() = %q, want pong:hi", got)
|
||||
}
|
||||
if fake.invokeRequest.GetCommand().GetPing().GetMessage() != "hi" {
|
||||
t.Fatalf("ping payload not sent: %v", fake.invokeRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionGetSessionState(t *testing.T) {
|
||||
fake := &fakeGatewayServer{invokeReply: &pb.MxCommandReply{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_GET_SESSION_STATE,
|
||||
ProtocolStatus: okStatus(),
|
||||
Payload: &pb.MxCommandReply_SessionState{SessionState: &pb.SessionStateReply{
|
||||
State: pb.SessionState_SESSION_STATE_CREATING,
|
||||
}},
|
||||
}}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
got, err := session.GetSessionState(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionState() error = %v", err)
|
||||
}
|
||||
if got != pb.SessionState_SESSION_STATE_CREATING {
|
||||
t.Fatalf("state = %v", got)
|
||||
}
|
||||
if fake.invokeRequest.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_GET_SESSION_STATE {
|
||||
t.Fatalf("kind = %v", fake.invokeRequest.GetCommand().GetKind())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionGetWorkerInfo(t *testing.T) {
|
||||
fake := &fakeGatewayServer{invokeReply: &pb.MxCommandReply{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_GET_WORKER_INFO,
|
||||
ProtocolStatus: okStatus(),
|
||||
Payload: &pb.MxCommandReply_WorkerInfo{WorkerInfo: &pb.WorkerInfoReply{
|
||||
WorkerProcessId: 42, WorkerVersion: "1.2.3", MxaccessProgid: "prog", MxaccessClsid: "clsid",
|
||||
}},
|
||||
}}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
info, err := session.GetWorkerInfo(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkerInfo() error = %v", err)
|
||||
}
|
||||
if info.GetWorkerProcessId() != 42 || info.GetWorkerVersion() != "1.2.3" {
|
||||
t.Fatalf("info = %v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionDrainEvents(t *testing.T) {
|
||||
fake := &fakeGatewayServer{invokeReply: &pb.MxCommandReply{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_DRAIN_EVENTS,
|
||||
ProtocolStatus: okStatus(),
|
||||
Payload: &pb.MxCommandReply_DrainEvents{DrainEvents: &pb.DrainEventsReply{
|
||||
Events: []*pb.MxEvent{{WorkerSequence: 1}, {WorkerSequence: 2}},
|
||||
}},
|
||||
}}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
events, err := session.DrainEvents(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DrainEvents() error = %v", err)
|
||||
}
|
||||
if len(events) != 2 || events[1].GetWorkerSequence() != 2 {
|
||||
t.Fatalf("events = %v", events)
|
||||
}
|
||||
if fake.invokeRequest.GetCommand().GetDrainEvents().GetMaxEvents() != 10 {
|
||||
t.Fatalf("max_events not sent: %v", fake.invokeRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionDiagnosticsErrorOnMissingPayload(t *testing.T) {
|
||||
fake := &fakeGatewayServer{}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
if _, err := session.GetWorkerInfo(context.Background()); err == nil {
|
||||
t.Fatal("GetWorkerInfo() with empty reply: want error")
|
||||
}
|
||||
if _, err := session.GetSessionState(context.Background()); err == nil {
|
||||
t.Fatal("GetSessionState() with empty reply: want error")
|
||||
}
|
||||
if _, err := session.DrainEvents(context.Background(), 1); err == nil {
|
||||
t.Fatal("DrainEvents() with empty reply: want error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -180,7 +182,20 @@ func (e *MalformedReplyError) Error() string {
|
||||
return fmt.Sprintf("mxgateway: %s returned a malformed reply: %s", e.Op, e.Detail)
|
||||
}
|
||||
|
||||
// GatewayError wraps transport-level gRPC failures.
|
||||
// ErrUnauthenticated matches, via errors.Is, a GatewayError whose gRPC status is
|
||||
// Unauthenticated: the gateway did not accept the API key (missing, malformed,
|
||||
// unknown, or revoked). Retrying with the same key will not help.
|
||||
var ErrUnauthenticated = errors.New("mxgateway: unauthenticated")
|
||||
|
||||
// ErrPermissionDenied matches, via errors.Is, a GatewayError whose gRPC status
|
||||
// is PermissionDenied: the API key is valid but lacks the scope the RPC needs, or
|
||||
// the session belongs to another key. It is distinct from ErrUnauthenticated so a
|
||||
// caller can tell "fix the key" from "grant the key a scope".
|
||||
var ErrPermissionDenied = errors.New("mxgateway: permission denied")
|
||||
|
||||
// GatewayError wraps transport-level gRPC failures. errors.Is(err,
|
||||
// ErrUnauthenticated) and errors.Is(err, ErrPermissionDenied) classify the two
|
||||
// authorization failures; Code returns the raw gRPC status code.
|
||||
type GatewayError struct {
|
||||
// Op names the operation that failed (for example "dial" or "invoke").
|
||||
Op string
|
||||
@@ -207,6 +222,32 @@ func (e *GatewayError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// Code returns the gRPC status code of the wrapped error, codes.Unknown for a
|
||||
// non-gRPC error, and codes.OK for a nil receiver or nil wrapped error.
|
||||
func (e *GatewayError) Code() codes.Code {
|
||||
if e == nil || e.Err == nil {
|
||||
return codes.OK
|
||||
}
|
||||
return status.Code(e.Err)
|
||||
}
|
||||
|
||||
// Is reports whether target is the sentinel matching this error's gRPC status
|
||||
// code (ErrUnauthenticated or ErrPermissionDenied). The check reads only the
|
||||
// status code, never the message, so credential redaction is unaffected.
|
||||
func (e *GatewayError) Is(target error) bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
switch target {
|
||||
case ErrUnauthenticated:
|
||||
return e.Code() == codes.Unauthenticated
|
||||
case ErrPermissionDenied:
|
||||
return e.Code() == codes.PermissionDenied
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CommandError reports a non-OK gateway protocol status and keeps the raw
|
||||
// command reply when one exists.
|
||||
type CommandError struct {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package mxgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// #198 — the gateway answers a bad key with Unauthenticated and a missing scope
|
||||
// with PermissionDenied; callers must be able to tell them apart with errors.Is.
|
||||
|
||||
func TestGatewayErrorMatchesAuthSentinelsByStatusCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
unauthenticated bool
|
||||
permissionDenied bool
|
||||
}{
|
||||
{"unauthenticated", status.Error(codes.Unauthenticated, "bad key"), true, false},
|
||||
{"permission denied", status.Error(codes.PermissionDenied, "scope required"), false, true},
|
||||
{"unavailable", status.Error(codes.Unavailable, "down"), false, false},
|
||||
{"non-grpc", errors.New("plain"), false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := fmt.Errorf("outer: %w", &GatewayError{Op: "invoke", Err: tc.err})
|
||||
if got := errors.Is(err, ErrUnauthenticated); got != tc.unauthenticated {
|
||||
t.Fatalf("errors.Is(ErrUnauthenticated) = %v, want %v", got, tc.unauthenticated)
|
||||
}
|
||||
if got := errors.Is(err, ErrPermissionDenied); got != tc.permissionDenied {
|
||||
t.Fatalf("errors.Is(ErrPermissionDenied) = %v, want %v", got, tc.permissionDenied)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayErrorCode(t *testing.T) {
|
||||
if got := (&GatewayError{Err: status.Error(codes.PermissionDenied, "x")}).Code(); got != codes.PermissionDenied {
|
||||
t.Fatalf("Code() = %v", got)
|
||||
}
|
||||
if got := (*GatewayError)(nil).Code(); got != codes.OK {
|
||||
t.Fatalf("nil Code() = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSentinelsSurviveTheWireAndRedaction(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
code codes.Code
|
||||
sentinel error
|
||||
other error
|
||||
}{
|
||||
{codes.Unauthenticated, ErrUnauthenticated, ErrPermissionDenied},
|
||||
{codes.PermissionDenied, ErrPermissionDenied, ErrUnauthenticated},
|
||||
} {
|
||||
t.Run(tc.code.String(), func(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{acknowledgeError: status.Error(tc.code, "denied 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 !errors.Is(err, tc.sentinel) {
|
||||
t.Fatalf("errors.Is(%v) = false for %v", tc.sentinel, err)
|
||||
}
|
||||
if errors.Is(err, tc.other) {
|
||||
t.Fatalf("errors.Is(%v) = true for %v", tc.other, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "pw-secret") {
|
||||
t.Fatalf("error leaked the operator password: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -99,11 +99,10 @@ func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) {
|
||||
grpc.WithTransportCredentials(transportCredentials),
|
||||
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
|
||||
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
|
||||
grpc.WithBlock(),
|
||||
}
|
||||
dialOptions = append(dialOptions, opts.DialOptions...)
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
|
||||
conn, err := dialConnected(dialCtx, opts.Endpoint, dialOptions)
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "dial", Err: err}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,21 @@ type Options struct {
|
||||
// no CACertFile is pinned. Default false: the gateway's self-signed cert is
|
||||
// accepted without verification (internal-tool posture).
|
||||
RequireCertificateValidation bool
|
||||
// EventBufferSize is the number of data results the channel returned by
|
||||
// Session.Events/EventsAfter buffers before a slow consumer is cancelled with
|
||||
// ErrSlowConsumer. Zero or negative applies DefaultEventBufferSize. It does not
|
||||
// affect SubscribeEvents, which blocks instead of dropping.
|
||||
EventBufferSize int
|
||||
}
|
||||
|
||||
// DefaultEventBufferSize is the default Options.EventBufferSize.
|
||||
const DefaultEventBufferSize = 1024
|
||||
|
||||
func (o Options) eventBufferSize() int {
|
||||
if o.EventBufferSize <= 0 {
|
||||
return DefaultEventBufferSize
|
||||
}
|
||||
return o.EventBufferSize
|
||||
}
|
||||
|
||||
// BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by
|
||||
|
||||
@@ -18,8 +18,9 @@ import (
|
||||
|
||||
const maxBulkItems = 1000
|
||||
|
||||
// eventBufferSize is the number of buffered event slots on the
|
||||
// Events/EventsAfter (cancel-when-full) results channel.
|
||||
// eventBufferSize is the data-slot count the unit tests use for the legacy
|
||||
// fixed-size assertions; the runtime value comes from Options.EventBufferSize
|
||||
// (default DefaultEventBufferSize).
|
||||
const eventBufferSize = 16
|
||||
|
||||
// eventBufferReservedSlots is the extra capacity reserved beyond
|
||||
@@ -548,8 +549,9 @@ func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, ent
|
||||
// MXAccess COM has no synchronous Read; the worker satisfies this by returning the
|
||||
// most recent cached OnDataChange value when the tag is already advised (WasCached=true),
|
||||
// or by taking a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
|
||||
// otherwise. timeout bounds the wait per tag in the snapshot case; pass zero to use the
|
||||
// worker default. Per-tag failures (timeout, invalid tag) appear as BulkReadResult entries
|
||||
// otherwise. timeout is one shared deadline for the whole command, not a per-tag budget:
|
||||
// the worker advises every uncached tag, waits once for all of them, then tears them down.
|
||||
// Pass zero to use the worker default (1000 ms). Per-tag failures (timeout, invalid tag) appear as BulkReadResult entries
|
||||
// with WasSuccessful=false; the call never returns an error for per-tag MXAccess failures.
|
||||
//
|
||||
// A non-nil but empty tagAddresses slice is treated as a no-op and returns an empty
|
||||
@@ -666,6 +668,68 @@ func buildSparseArrayValue(elementDataType MxDataType, totalLength uint32, eleme
|
||||
}
|
||||
}
|
||||
|
||||
// Ping sends a diagnostic PING command and returns the message the gateway
|
||||
// echoed back in the reply's DiagnosticMessage field.
|
||||
func (s *Session) Ping(ctx context.Context, message string) (string, error) {
|
||||
reply, err := s.PingRaw(ctx, message)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return reply.GetDiagnosticMessage(), nil
|
||||
}
|
||||
|
||||
// GetSessionState returns the gateway's current lifecycle state for the session.
|
||||
func (s *Session) GetSessionState(ctx context.Context) (SessionState, error) {
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_GET_SESSION_STATE,
|
||||
Payload: &pb.MxCommand_GetSessionState{GetSessionState: &pb.GetSessionStateCommand{}},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
payload := reply.GetSessionState()
|
||||
if payload == nil {
|
||||
return 0, errors.New("mxgateway: get session state reply carried no session_state payload")
|
||||
}
|
||||
return payload.GetState(), nil
|
||||
}
|
||||
|
||||
// GetWorkerInfo returns diagnostic information about the session's worker
|
||||
// process (process id, version, MXAccess ProgID and CLSID).
|
||||
func (s *Session) GetWorkerInfo(ctx context.Context) (*WorkerInfoReply, error) {
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_GET_WORKER_INFO,
|
||||
Payload: &pb.MxCommand_GetWorkerInfo{GetWorkerInfo: &pb.GetWorkerInfoCommand{}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := reply.GetWorkerInfo()
|
||||
if payload == nil {
|
||||
return nil, errors.New("mxgateway: get worker info reply carried no worker_info payload")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// DrainEvents removes and returns up to maxEvents queued events through the
|
||||
// diagnostic DRAIN_EVENTS command. The reply is bounded server-side, so it can
|
||||
// carry fewer events than requested or queued; call repeatedly until it returns
|
||||
// an empty slice. It is a diagnostic tool, not a replacement for Events.
|
||||
func (s *Session) DrainEvents(ctx context.Context, maxEvents uint32) ([]*MxEvent, error) {
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_DRAIN_EVENTS,
|
||||
Payload: &pb.MxCommand_DrainEvents{DrainEvents: &pb.DrainEventsCommand{MaxEvents: maxEvents}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := reply.GetDrainEvents()
|
||||
if payload == nil {
|
||||
return nil, errors.New("mxgateway: drain events reply carried no drain_events payload")
|
||||
}
|
||||
return payload.GetEvents(), nil
|
||||
}
|
||||
|
||||
// PingRaw sends a diagnostic PING command and returns the raw reply.
|
||||
// The message is echoed back by the gateway in the reply's DiagnosticMessage field.
|
||||
func (s *Session) PingRaw(ctx context.Context, message string) (*MxCommandReply, error) {
|
||||
@@ -1001,7 +1065,9 @@ func stringSecrets(values ...*MxValue) []string {
|
||||
// The returned channel is buffered. If the consumer falls behind and the buffer
|
||||
// overflows with data, the stream is terminated and a final EventResult carrying
|
||||
// a GatewayError that wraps ErrSlowConsumer is delivered before the channel
|
||||
// closes; match it with errors.Is(res.Err, ErrSlowConsumer) to distinguish a
|
||||
// closes. Results already buffered are delivered first, in order, ahead of that
|
||||
// terminal result. The buffer holds Options.EventBufferSize data results
|
||||
// (default DefaultEventBufferSize); match it with errors.Is(res.Err, ErrSlowConsumer) to distinguish a
|
||||
// slow-consumer drop from a graceful server end. A genuine stream error is
|
||||
// reported as itself even under overflow — it is never relabeled as
|
||||
// ErrSlowConsumer, so the underlying gRPC status stays inspectable. Use
|
||||
@@ -1046,7 +1112,8 @@ func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make(chan EventResult, eventBufferSize+eventBufferReservedSlots)
|
||||
dataSlots := s.client.opts.eventBufferSize()
|
||||
results := make(chan EventResult, dataSlots+eventBufferReservedSlots)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(results)
|
||||
@@ -1062,7 +1129,7 @@ func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence
|
||||
if gap := event.GetReplayGap(); gap != nil {
|
||||
result = EventResult{ReplayGap: gap}
|
||||
}
|
||||
if !sendEventResult(streamCtx, results, result, cancelWhenResultBufferFull, cancel) {
|
||||
if !sendEventResult(streamCtx, results, result, dataSlots, cancelWhenResultBufferFull, cancel) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
@@ -1126,6 +1193,7 @@ func sendEventResult(
|
||||
ctx context.Context,
|
||||
results chan<- EventResult,
|
||||
result EventResult,
|
||||
dataSlots int,
|
||||
cancelWhenBufferFull bool,
|
||||
cancel context.CancelFunc,
|
||||
) bool {
|
||||
@@ -1136,7 +1204,7 @@ func sendEventResult(
|
||||
// grows by our own sends and shrinks as the consumer reads; the reserve
|
||||
// therefore always survives to carry ErrSlowConsumer. A plain buffered
|
||||
// send would instead consume the reserved slot as ordinary data.
|
||||
if len(results) >= eventBufferSize {
|
||||
if len(results) >= dataSlots {
|
||||
// The consumer fell behind and the data slots are full. Cancel the
|
||||
// stream, then use the reserved terminal slot to enqueue a loud
|
||||
// ErrSlowConsumer result so overflow is always observable rather
|
||||
|
||||
@@ -28,6 +28,8 @@ type (
|
||||
MxCommandReply = pb.MxCommandReply
|
||||
// MxCommand is the discriminated union of MXAccess command payloads.
|
||||
MxCommand = pb.MxCommand
|
||||
// WorkerInfoReply is the GetWorkerInfo diagnostic reply payload.
|
||||
WorkerInfoReply = pb.WorkerInfoReply
|
||||
// MxEvent is one ordered event delivered on a session event stream.
|
||||
MxEvent = pb.MxEvent
|
||||
// ReplayGap is the gateway sentinel payload signalling that a resumed event
|
||||
@@ -127,9 +129,14 @@ type (
|
||||
QueryActiveAlarmsRequest = pb.QueryActiveAlarmsRequest
|
||||
// StreamAlarmsRequest is the gateway StreamAlarms request message.
|
||||
StreamAlarmsRequest = pb.StreamAlarmsRequest
|
||||
// AlarmFeedMessage is one message on the StreamAlarms feed — an
|
||||
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
|
||||
// AlarmFeedMessage is one message on the StreamAlarms feed — an active-alarm
|
||||
// snapshot row, snapshot-complete sentinel, transition, provider status, or
|
||||
// snapshot-completeness status.
|
||||
AlarmFeedMessage = pb.AlarmFeedMessage
|
||||
// AlarmProviderStatus reports the alarm provider mode and degradation state.
|
||||
AlarmProviderStatus = pb.AlarmProviderStatus
|
||||
// AlarmSnapshotStatus reports whether the cached alarm snapshot is truncated.
|
||||
AlarmSnapshotStatus = pb.AlarmSnapshotStatus
|
||||
// ActiveAlarmSnapshot is one row in a ConditionRefresh stream.
|
||||
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
|
||||
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package mxgateway_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||
)
|
||||
|
||||
// TestAlarmFeedStatusAliasesArePublic is a compile-time consumer contract for
|
||||
// the two status payloads carried by AlarmFeedMessage.
|
||||
func TestAlarmFeedStatusAliasesArePublic(t *testing.T) {
|
||||
t.Helper()
|
||||
// Pointers, not values: protobuf messages embed a mutex and must not be copied.
|
||||
var provider *mxgateway.AlarmProviderStatus
|
||||
var snapshot *mxgateway.AlarmSnapshotStatus
|
||||
_ = provider
|
||||
_ = snapshot
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package mxgateway
|
||||
const (
|
||||
// ClientVersion is the released semantic version of this Go client module.
|
||||
// Keep it in sync with the module tag applied by scripts/tag-go-module.ps1.
|
||||
ClientVersion = "0.2.0"
|
||||
ClientVersion = "0.2.1"
|
||||
|
||||
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
|
||||
// in the shared .NET contracts.
|
||||
|
||||
+55
-5
@@ -79,6 +79,30 @@ only stops the client from waiting; it does not abort an in-flight MXAccess COM
|
||||
call on the worker STA. It is a **single-consumer** surface: drive
|
||||
`hasNext()`/`next()` (or `nextItem()`) from one thread only.
|
||||
|
||||
### Event buffer, overflow, and interrupts
|
||||
|
||||
The iterator buffers events in a bounded queue whose size is
|
||||
`MxGatewayClientOptions.builder().eventQueueCapacity(n)` (default 1024; it was a
|
||||
hard-coded 16, which a short burst could overflow). If the consumer falls more
|
||||
than `n` events behind, the client cancels the call, keeps the events already
|
||||
buffered, and throws `MxGatewayException` only after you have drained them. Resume
|
||||
with `streamEventsAfter(lastSeenWorkerSequence)`. If the consuming thread is
|
||||
interrupted, the call is cancelled, the interrupt flag is restored, and the
|
||||
iterator throws a `CANCELLED` `StatusRuntimeException`.
|
||||
|
||||
`streamEventsAsync(request, StreamObserver<MxEvent>)` is the non-dropping
|
||||
alternative: it delivers each event to your observer on the gRPC thread with no
|
||||
client-side queue, so nothing is discarded by the client (a slow observer
|
||||
instead applies normal gRPC flow control).
|
||||
|
||||
### Session diagnostics
|
||||
|
||||
`MxGatewaySession` exposes typed helpers for the worker diagnostic commands:
|
||||
`ping(message)` (returns the raw `MxCommandReply`), `getSessionState()`
|
||||
(`SessionState`), `getWorkerInfo()` (`WorkerInfoReply`: process id, version,
|
||||
ProgID, CLSID) and `drainEvents(maxEvents)` (`List<MxEvent>`; replies are
|
||||
bounded server-side, so loop until an empty list).
|
||||
|
||||
### Reconnect-replay gap signal
|
||||
|
||||
When you resume a stream with `streamEventsAfter(afterWorkerSequence)` and the
|
||||
@@ -114,8 +138,34 @@ try (MxEventStream events = session.streamEventsAfter(lastSeenSequence)) {
|
||||
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
|
||||
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
|
||||
yields alarm-feed messages from the gateway's central monitor), and
|
||||
`acknowledgeAlarm` (ack by full alarm reference with an optional comment and
|
||||
ack target). Close the subscription to cancel the underlying gRPC stream.
|
||||
`acknowledgeAlarm` (ack by full alarm reference with an optional comment).
|
||||
The ack carries operator credentials: `operator_user` is required, and
|
||||
`operator_password` / `operator_domain` let the gateway authenticate as that
|
||||
operator so the Historian records the real operator. An empty password is
|
||||
allowed. The password is scrubbed from exceptions the client throws. The operator's full name is not sent by the client and is not propagated to MXAccess (`operator_full_name` on the worker commands is accepted but ignored); the Historian takes it from the authenticated Galaxy account. Close the subscription to cancel the underlying gRPC stream.
|
||||
|
||||
`ActiveAlarmSnapshot.getFromTruncatedSnapshot()` reports that the record came
|
||||
from a provider fetch which hit the per-fetch cap: the snapshot set may omit
|
||||
active alarms, and the gateway suspended its absence-implies-cleared inference
|
||||
for that poll. Treat the set as possibly incomplete rather than reconciling
|
||||
deletions from it. It is set-level degraded status, not a comment on the
|
||||
record's own fidelity, and is distinct from `getDegraded()` (the subtag
|
||||
fallback provider).
|
||||
|
||||
`streamAlarms` also carries that completeness verdict at feed level, as a
|
||||
message whose `getPayloadCase()` is `SNAPSHOT_STATUS` and whose
|
||||
`getSnapshotStatus().getTruncated()` is true while the monitor's cached set
|
||||
derives from a truncated fetch. One arrives at stream open (after the
|
||||
`PROVIDER_STATUS` frame, before the cached `ACTIVE_ALARM` frames) so a late
|
||||
joiner learns the current verdict, then one on every verdict change — including
|
||||
the clearing frame sent when the gateway's alarm monitor restarts and drops a
|
||||
truncated verdict. Track it if you need set completeness on a live feed without
|
||||
polling `queryActiveAlarms`.
|
||||
|
||||
The CLI's text rows always spell out `degraded=false` and `truncated=false`.
|
||||
Its protobuf-JSON mode retains the standard proto3 omission rule, so those
|
||||
false-valued payloads appear as `{"providerStatus":{}}` and
|
||||
`{"snapshotStatus":{}}`; absent means false.
|
||||
|
||||
## Write Semantics And Common Pitfalls
|
||||
|
||||
@@ -393,8 +443,8 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="advise-supervisory --endpoint localh
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="authenticate-user --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --verify-user operator --password-env MXGATEWAY_VERIFY_PASSWORD --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-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 1 --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="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\" --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"
|
||||
```
|
||||
|
||||
@@ -465,7 +515,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.2.1'
|
||||
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.2.2'
|
||||
}
|
||||
````
|
||||
|
||||
|
||||
@@ -16,11 +16,14 @@ subprojects {
|
||||
// 0.2.0 was already published to the Gitea Maven feed on 2026-06-26,
|
||||
// before the CLI-37/38/40/41 conformance fixes changed the client's
|
||||
// observable behavior (status.category-based validation, hresult < 0,
|
||||
// exact-secret redaction, typed malformed-reply errors). Bump to 0.2.1
|
||||
// so the published coordinate matches the conformant behavior the other
|
||||
// four clients ship at 0.2.0 for the first time. See CLI-39 and the
|
||||
// exact-secret redaction, typed malformed-reply errors). Bumped to 0.2.1
|
||||
// so the published coordinate matched the conformant behavior the other
|
||||
// four clients shipped at 0.2.0 for the first time. See CLI-39 and the
|
||||
// "Versioning" section of docs/ClientPackaging.md.
|
||||
version = '0.2.1'
|
||||
// 0.2.1 was itself published (Maven, 2026-08-07); this is the next
|
||||
// release-train bump (CLI-46), one ahead of the other four clients'
|
||||
// 0.2.1 because the Java coordinate has always trailed by one number.
|
||||
version = '0.2.2'
|
||||
|
||||
pluginManager.withPlugin('java') {
|
||||
java {
|
||||
|
||||
+3848
-603
File diff suppressed because it is too large
Load Diff
@@ -216,6 +216,13 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
|
||||
*/
|
||||
WORKER_FAULT_CATEGORY_UNSPECIFIED(0),
|
||||
/**
|
||||
* <pre>
|
||||
* Values 1 and 2 are reserved: never emitted. Bootstrap failures (bad
|
||||
* arguments, gateway authentication) exit via WorkerExitCode before the pipe
|
||||
* exists, so there is no channel to carry a fault. Names kept so the numbers
|
||||
* are not reused.
|
||||
* </pre>
|
||||
*
|
||||
* <code>WORKER_FAULT_CATEGORY_INVALID_ARGUMENTS = 1;</code>
|
||||
*/
|
||||
WORKER_FAULT_CATEGORY_INVALID_ARGUMENTS(1),
|
||||
@@ -276,6 +283,13 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
|
||||
*/
|
||||
public static final int WORKER_FAULT_CATEGORY_UNSPECIFIED_VALUE = 0;
|
||||
/**
|
||||
* <pre>
|
||||
* Values 1 and 2 are reserved: never emitted. Bootstrap failures (bad
|
||||
* arguments, gateway authentication) exit via WorkerExitCode before the pipe
|
||||
* exists, so there is no channel to carry a fault. Names kept so the numbers
|
||||
* are not reused.
|
||||
* </pre>
|
||||
*
|
||||
* <code>WORKER_FAULT_CATEGORY_INVALID_ARGUMENTS = 1;</code>
|
||||
*/
|
||||
public static final int WORKER_FAULT_CATEGORY_INVALID_ARGUMENTS_VALUE = 1;
|
||||
|
||||
+37
-4
@@ -47,8 +47,9 @@ import java.util.function.Consumer;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
@@ -1261,7 +1262,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
@Option(
|
||||
names = "--timeout-ms",
|
||||
defaultValue = "0",
|
||||
description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
|
||||
description = "Snapshot deadline in milliseconds, shared by all tags in the command (0 = worker default, 1000 ms).")
|
||||
int timeoutMs;
|
||||
|
||||
ReadBulkCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
@@ -1509,7 +1510,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
@Option(
|
||||
names = "--timeout-ms",
|
||||
defaultValue = "0",
|
||||
description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
|
||||
description = "Snapshot deadline in milliseconds, shared by all tags in the command (0 = worker default, 1000 ms).")
|
||||
int timeoutMs;
|
||||
|
||||
@Option(names = "--iterations", defaultValue = "10", description = "Number of ReadBulk calls to perform.")
|
||||
@@ -1821,6 +1822,31 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
@Option(names = "--operator", description = "Operator user performing the acknowledge.")
|
||||
String operator = "";
|
||||
|
||||
@Option(names = "--operator-domain", description = "Operator Galaxy domain (optional).")
|
||||
String operatorDomain = "";
|
||||
|
||||
@Option(
|
||||
names = "--operator-password",
|
||||
description = "Operator Galaxy password (credential; prefer --operator-password-env). Never echoed.")
|
||||
String operatorPassword;
|
||||
|
||||
@Option(
|
||||
names = "--operator-password-env",
|
||||
defaultValue = "MXGATEWAY_OPERATOR_PASSWORD",
|
||||
description = "Environment variable holding the operator password when --operator-password is omitted.")
|
||||
String operatorPasswordEnv;
|
||||
|
||||
private String resolveOperatorPassword() {
|
||||
if (operatorPassword != null && !operatorPassword.isEmpty()) {
|
||||
return operatorPassword;
|
||||
}
|
||||
String name = operatorPasswordEnv == null || operatorPasswordEnv.isBlank()
|
||||
? "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
: operatorPasswordEnv;
|
||||
String value = System.getenv(name);
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
AcknowledgeAlarmCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
@@ -1832,6 +1858,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
.setAlarmFullReference(reference)
|
||||
.setComment(comment)
|
||||
.setOperatorUser(operator)
|
||||
.setOperatorDomain(operatorDomain == null ? "" : operatorDomain)
|
||||
.setOperatorPassword(resolveOperatorPassword())
|
||||
.build());
|
||||
writeOutput(
|
||||
"acknowledge-alarm",
|
||||
@@ -2282,7 +2310,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
/**
|
||||
* Renders one {@link AlarmFeedMessage} in the CLI's plain-text output
|
||||
* style, distinguishing the active-alarm snapshot, snapshot-complete
|
||||
* sentinel, and transition cases of the message's {@code payload} oneof.
|
||||
* sentinel, transition, provider-status, and snapshot-status cases of the
|
||||
* message's {@code payload} oneof.
|
||||
*/
|
||||
private static String formatAlarmFeedMessage(AlarmFeedMessage message) {
|
||||
return switch (message.getPayloadCase()) {
|
||||
@@ -2307,6 +2336,10 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
"provider-status mode=%s degraded=%b reason=%s",
|
||||
status.getMode().name(), status.getDegraded(), status.getReason());
|
||||
}
|
||||
case SNAPSHOT_STATUS -> {
|
||||
AlarmSnapshotStatus status = message.getSnapshotStatus();
|
||||
yield String.format("snapshot-status truncated=%b", status.getTruncated());
|
||||
}
|
||||
case PAYLOAD_NOT_SET -> "unknown";
|
||||
};
|
||||
}
|
||||
|
||||
+36
-2
@@ -27,6 +27,8 @@ import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||
@@ -60,7 +62,7 @@ final class MxGatewayCliTests {
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals("", run.errors());
|
||||
assertTrue(run.output().contains("mxgateway-java 0.2.1"));
|
||||
assertTrue(run.output().contains("mxgateway-java 0.2.2"));
|
||||
assertTrue(run.output().contains("gatewayProtocolVersion=3"));
|
||||
assertTrue(run.output().contains("workerProtocolVersion=1"));
|
||||
}
|
||||
@@ -90,7 +92,7 @@ final class MxGatewayCliTests {
|
||||
CliRun run = execute(new FakeClientFactory(), "version", "--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertTrue(run.output().contains("\"clientVersion\":\"0.2.1\""));
|
||||
assertTrue(run.output().contains("\"clientVersion\":\"0.2.2\""));
|
||||
assertTrue(run.output().contains("\"gatewayProtocolVersion\":3"));
|
||||
}
|
||||
|
||||
@@ -685,6 +687,21 @@ final class MxGatewayCliTests {
|
||||
assertTrue(run.output().contains("\"snapshotComplete\""), run.output());
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamAlarmsCommandPrintsStatusArmsInTextAndProtobufJson() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
|
||||
CliRun text = execute(factory, "stream-alarms");
|
||||
List<String> textRows = text.output().lines().filter(line -> !line.isBlank()).toList();
|
||||
assertEquals("provider-status mode=ALARM_PROVIDER_MODE_UNSPECIFIED degraded=false reason=", textRows.get(3));
|
||||
assertEquals("snapshot-status truncated=false", textRows.get(4));
|
||||
|
||||
CliRun json = execute(factory, "stream-alarms", "--json");
|
||||
List<String> jsonRows = json.output().lines().filter(line -> !line.isBlank()).toList();
|
||||
assertEquals("{\"providerStatus\":{}}", jsonRows.get(3));
|
||||
assertEquals("{\"snapshotStatus\":{}}", jsonRows.get(4));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmCommandForwardsOptionsAndPrintsReply() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
@@ -697,9 +714,16 @@ final class MxGatewayCliTests {
|
||||
"checked",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password",
|
||||
"pw-secret",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals("CORP", factory.client.lastAcknowledgeAlarmRequest.getOperatorDomain());
|
||||
assertEquals("pw-secret", factory.client.lastAcknowledgeAlarmRequest.getOperatorPassword());
|
||||
assertFalse(run.output().contains("pw-secret"), run.output());
|
||||
assertEquals("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
|
||||
assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||
assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
|
||||
@@ -753,6 +777,10 @@ final class MxGatewayCliTests {
|
||||
"--plaintext",
|
||||
"--reference",
|
||||
"\\Galaxy\\Area001.Pump001.PumpFault",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-password-env",
|
||||
"MXGATEWAY_OPERATOR_PASSWORD",
|
||||
"--json"
|
||||
};
|
||||
assertReadmeExampleParses(args);
|
||||
@@ -1409,6 +1437,12 @@ final class MxGatewayCliTests {
|
||||
.setTransitionKind(AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE)
|
||||
.setSeverity(700))
|
||||
.build());
|
||||
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||
.setProviderStatus(AlarmProviderStatus.newBuilder().setDegraded(false))
|
||||
.build());
|
||||
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||
.setSnapshotStatus(AlarmSnapshotStatus.newBuilder().setTruncated(false))
|
||||
.build());
|
||||
observer.onCompleted();
|
||||
return new MxGatewayAlarmFeedSubscription();
|
||||
}
|
||||
|
||||
@@ -72,16 +72,18 @@ tasks.register('checkGeneratedClean') {
|
||||
group = 'verification'
|
||||
description = 'Fails if the committed generated Java tree differs from a fresh regeneration.'
|
||||
dependsOn 'generateProto'
|
||||
def generatedDir = 'clients/java/src/main/generated'
|
||||
def repoRoot = rootProject.projectDir.parentFile.parentFile
|
||||
// Project.exec was removed in Gradle 9; ProviderFactory.exec runs the git-status probe
|
||||
// lazily (captured at configuration time, evaluated in doLast) and works on both the
|
||||
// installed Gradle and Gradle 9.
|
||||
def gitStatus = providers.exec {
|
||||
workingDir(repoRoot)
|
||||
commandLine('git', 'status', '--porcelain', '--', generatedDir)
|
||||
ignoreExitValue = true
|
||||
}
|
||||
doLast {
|
||||
def generatedDir = 'clients/java/src/main/generated'
|
||||
def stdout = new ByteArrayOutputStream()
|
||||
def result = exec {
|
||||
workingDir = rootProject.projectDir.parentFile.parentFile
|
||||
commandLine 'git', 'status', '--porcelain', '--', generatedDir
|
||||
standardOutput = stdout
|
||||
ignoreExitValue = true
|
||||
}
|
||||
def dirty = stdout.toString().trim()
|
||||
def dirty = gitStatus.standardOutput.asText.get().trim()
|
||||
if (!dirty.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"Generated Java is stale:\n${dirty}\n" +
|
||||
|
||||
+34
-6
@@ -19,8 +19,12 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
* <p>Events arrive on a background gRPC thread and are buffered in a bounded
|
||||
* blocking queue; the iterator drains them on the calling thread. Closing the
|
||||
* stream cancels the underlying gRPC call. If the queue overflows the call is
|
||||
* cancelled and a follow-up call to {@link #next()} throws
|
||||
* {@link MxGatewayException}.
|
||||
* cancelled, the events already buffered remain readable, and the
|
||||
* {@link MxGatewayException} is thrown only after they are drained (the consumer
|
||||
* can then resume with {@code after_worker_sequence}). The capacity comes from
|
||||
* {@link MxGatewayClientOptions#eventQueueCapacity()}. Callers that must never be
|
||||
* dropped should use {@link MxGatewayClient#streamEventsAsync}, which delivers on
|
||||
* the gRPC thread with the caller's own flow control.
|
||||
*
|
||||
* <p><strong>Single consumer.</strong> This stream is not safe to drain from
|
||||
* more than one thread. Interleave {@link #hasNext()}/{@link #next()} (or
|
||||
@@ -46,10 +50,15 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||
private final BlockingQueue<Object> queue;
|
||||
private volatile ClientCallStreamObserver<StreamEventsRequest> requestStream;
|
||||
private volatile boolean closed;
|
||||
private final int capacity;
|
||||
private boolean overflowed;
|
||||
private Object next;
|
||||
|
||||
MxEventStream(int capacity) {
|
||||
queue = new ArrayBlockingQueue<>(capacity);
|
||||
this.capacity = capacity;
|
||||
// Two extra slots are reserved so the overflow error and END sentinel
|
||||
// can always be enqueued behind a full buffer of received events.
|
||||
queue = new ArrayBlockingQueue<>(capacity + 2);
|
||||
}
|
||||
|
||||
ClientResponseObserver<StreamEventsRequest, MxEvent> observer() {
|
||||
@@ -143,12 +152,16 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||
return queue.take();
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
ClientCallStreamObserver<StreamEventsRequest> stream = requestStream;
|
||||
if (stream != null) {
|
||||
stream.cancel("interrupted while reading events", error);
|
||||
}
|
||||
return new StatusRuntimeException(Status.CANCELLED.withDescription("interrupted while reading events"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void offer(Object value) {
|
||||
private synchronized void offer(Object value) {
|
||||
Objects.requireNonNull(value, "value");
|
||||
if (value == END) {
|
||||
if (!queue.offer(value)) {
|
||||
@@ -157,14 +170,29 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!queue.offer(value)) {
|
||||
if (overflowed) {
|
||||
return;
|
||||
}
|
||||
if (value instanceof MxEvent) {
|
||||
if (queue.size() < capacity) {
|
||||
queue.offer(value);
|
||||
return;
|
||||
}
|
||||
// Keep the events already received so the consumer can drain them
|
||||
// and resume from the last one via after_worker_sequence; the
|
||||
// overflow error is delivered behind them.
|
||||
overflowed = true;
|
||||
ClientCallStreamObserver<StreamEventsRequest> stream = requestStream;
|
||||
if (stream != null) {
|
||||
stream.cancel("client event stream queue overflowed", null);
|
||||
}
|
||||
queue.clear();
|
||||
queue.offer(new MxGatewayException("gateway stream events queue overflowed"));
|
||||
queue.offer(END);
|
||||
return;
|
||||
}
|
||||
if (!queue.offer(value)) {
|
||||
queue.clear();
|
||||
queue.offer(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-5
@@ -245,7 +245,7 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* @return an iterator-style stream of events
|
||||
*/
|
||||
public MxEventStream streamEvents(StreamEventsRequest request) {
|
||||
MxEventStream stream = new MxEventStream(16);
|
||||
MxEventStream stream = new MxEventStream(options.eventQueueCapacity());
|
||||
withStreamDeadline(rawAsyncStub()).streamEvents(request, stream.observer());
|
||||
return stream;
|
||||
}
|
||||
@@ -273,6 +273,11 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* worker's MXAccess session; the resulting native MxStatus is returned
|
||||
* in the reply. Acks are idempotent at the MxAccess layer.
|
||||
*
|
||||
* <p>{@code operator_user} is required; the gateway authenticates it with
|
||||
* {@code operator_password} (and optional {@code operator_domain}) and the
|
||||
* Historian records that operator. An empty password is allowed. The
|
||||
* password is scrubbed from any exception this method throws.
|
||||
*
|
||||
* @param request the {@code AcknowledgeAlarmRequest}
|
||||
* @return the raw acknowledge reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
@@ -283,10 +288,14 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
} catch (RuntimeException error) {
|
||||
if (error instanceof MxGatewayException) {
|
||||
throw error;
|
||||
MxGatewayException mapped = error instanceof MxGatewayException gatewayError
|
||||
? gatewayError
|
||||
: (MxGatewayException) MxGatewayErrors.fromGrpc("acknowledge alarm", error);
|
||||
String secret = request.getOperatorPassword();
|
||||
if (secret.isEmpty()) {
|
||||
throw mapped;
|
||||
}
|
||||
throw MxGatewayErrors.fromGrpc("acknowledge alarm", error);
|
||||
throw MxGatewayErrors.redacted(mapped, secret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,10 +308,24 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
*/
|
||||
public CompletableFuture<AcknowledgeAlarmReply> acknowledgeAlarmAsync(AcknowledgeAlarmRequest request) {
|
||||
CompletableFuture<AcknowledgeAlarmReply> future = toCompletable(rawFutureStub().acknowledgeAlarm(request));
|
||||
return future.thenApply(reply -> {
|
||||
String secret = request.getOperatorPassword();
|
||||
CompletableFuture<AcknowledgeAlarmReply> checked = future.thenApply(reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
});
|
||||
if (secret.isEmpty()) {
|
||||
return checked;
|
||||
}
|
||||
// Scrub the operator password from any failure surfaced through the future.
|
||||
return checked.exceptionally(error -> {
|
||||
Throwable cause = error instanceof java.util.concurrent.CompletionException && error.getCause() != null
|
||||
? error.getCause()
|
||||
: error;
|
||||
if (cause instanceof MxGatewayException gatewayError) {
|
||||
throw new java.util.concurrent.CompletionException(MxGatewayErrors.redacted(gatewayError, secret));
|
||||
}
|
||||
throw new java.util.concurrent.CompletionException(cause);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+30
@@ -15,6 +15,8 @@ public final class MxGatewayClientOptions {
|
||||
private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Duration DEFAULT_CALL_TIMEOUT = Duration.ofSeconds(30);
|
||||
private static final int DEFAULT_MAX_GRPC_MESSAGE_BYTES = 16 * 1024 * 1024;
|
||||
/** Default {@code StreamEvents} iterator buffer; sized to absorb bursts without dropping a slow consumer. */
|
||||
static final int DEFAULT_EVENT_QUEUE_CAPACITY = 1024;
|
||||
|
||||
private final String endpoint;
|
||||
private final String apiKey;
|
||||
@@ -26,6 +28,7 @@ public final class MxGatewayClientOptions {
|
||||
private final Duration callTimeout;
|
||||
private final Duration streamTimeout;
|
||||
private final int maxGrpcMessageBytes;
|
||||
private final int eventQueueCapacity;
|
||||
|
||||
private MxGatewayClientOptions(Builder builder) {
|
||||
endpoint = requireText(builder.endpoint, "endpoint");
|
||||
@@ -40,6 +43,10 @@ public final class MxGatewayClientOptions {
|
||||
maxGrpcMessageBytes = builder.maxGrpcMessageBytes <= 0
|
||||
? DEFAULT_MAX_GRPC_MESSAGE_BYTES
|
||||
: builder.maxGrpcMessageBytes;
|
||||
if (builder.eventQueueCapacity <= 0) {
|
||||
throw new IllegalArgumentException("eventQueueCapacity must be positive");
|
||||
}
|
||||
eventQueueCapacity = builder.eventQueueCapacity;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +156,17 @@ public final class MxGatewayClientOptions {
|
||||
return maxGrpcMessageBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the capacity of the buffer behind {@link MxGatewayClient#streamEvents}.
|
||||
* A consumer that falls more than this many events behind has its stream
|
||||
* cancelled with an overflow error after the buffered events are drained.
|
||||
*
|
||||
* @return the event buffer capacity (default 1024)
|
||||
*/
|
||||
public int eventQueueCapacity() {
|
||||
return eventQueueCapacity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MxGatewayClientOptions{"
|
||||
@@ -199,6 +217,7 @@ public final class MxGatewayClientOptions {
|
||||
private Duration callTimeout;
|
||||
private Duration streamTimeout;
|
||||
private int maxGrpcMessageBytes;
|
||||
private int eventQueueCapacity = DEFAULT_EVENT_QUEUE_CAPACITY;
|
||||
|
||||
private Builder() {
|
||||
}
|
||||
@@ -314,6 +333,17 @@ public final class MxGatewayClientOptions {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the capacity of the iterator-style event stream buffer. Defaults to 1024.
|
||||
*
|
||||
* @param value the buffer capacity, must be positive
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder eventQueueCapacity(int value) {
|
||||
eventQueueCapacity = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an immutable {@link MxGatewayClientOptions} from the current state.
|
||||
*
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ package com.zb.mom.ww.mxgateway.client;
|
||||
public final class MxGatewayClientVersion {
|
||||
private static final int GATEWAY_PROTOCOL_VERSION = 3;
|
||||
private static final int WORKER_PROTOCOL_VERSION = 1;
|
||||
private static final String CLIENT_VERSION = "0.2.1";
|
||||
private static final String CLIENT_VERSION = "0.2.2";
|
||||
|
||||
private MxGatewayClientVersion() {
|
||||
}
|
||||
|
||||
+37
@@ -28,6 +28,43 @@ final class MxGatewayErrors {
|
||||
return new MxGatewayException("gateway " + operation + " failed: " + error.getMessage(), error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds a gateway exception with an exact secret scrubbed from its
|
||||
* message and protocol status. The original is not chained as a cause
|
||||
* because its message and cause chain may carry the secret.
|
||||
*/
|
||||
static MxGatewayException redacted(MxGatewayException error, String secret) {
|
||||
String message = MxGatewaySecrets.redactExact(error.getMessage(), secret);
|
||||
if (error instanceof MxAccessException ex) {
|
||||
return new MxAccessException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null);
|
||||
}
|
||||
if (error instanceof MxGatewayCommandException ex) {
|
||||
return new MxGatewayCommandException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null);
|
||||
}
|
||||
if (error instanceof MxGatewaySessionException ex) {
|
||||
return new MxGatewaySessionException(message, scrub(ex.protocolStatus(), secret), null);
|
||||
}
|
||||
if (error instanceof MxGatewayWorkerException ex) {
|
||||
return new MxGatewayWorkerException(message, scrub(ex.protocolStatus(), secret), null);
|
||||
}
|
||||
if (error instanceof MxGatewayAuthenticationException) {
|
||||
return new MxGatewayAuthenticationException(message, null);
|
||||
}
|
||||
if (error instanceof MxGatewayAuthorizationException) {
|
||||
return new MxGatewayAuthorizationException(message, null);
|
||||
}
|
||||
return new MxGatewayException(message);
|
||||
}
|
||||
|
||||
private static ProtocolStatus scrub(ProtocolStatus status, String secret) {
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
return status.toBuilder()
|
||||
.setMessage(MxGatewaySecrets.redactExact(status.getMessage(), secret))
|
||||
.build();
|
||||
}
|
||||
|
||||
static void ensureProtocolSuccess(String operation, ProtocolStatus status, MxCommandReply reply) {
|
||||
if (status == null || status.getCode() == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) {
|
||||
return;
|
||||
|
||||
+74
-4
@@ -37,6 +37,13 @@ import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SetBufferedUpdateIntervalCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.DrainEventsCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.GetSessionStateCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.GetWorkerInfoCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.PingCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WorkerInfoReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||
@@ -548,15 +555,17 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
* {@code OnDataChange} value for any tag that is already advised
|
||||
* ({@code wasCached == true}) without modifying the existing subscription,
|
||||
* and falls back to a full AddItem + Advise + wait + UnAdvise + RemoveItem
|
||||
* snapshot lifecycle otherwise. The supplied {@code timeout} bounds the
|
||||
* per-tag wait in the snapshot case; pass {@link Duration#ZERO} (or
|
||||
* {@code null}) to use the worker default (1000 ms). Per-tag failures
|
||||
* snapshot lifecycle otherwise. The supplied {@code timeout} is one shared
|
||||
* deadline for the whole command, not a per-tag budget: the worker advises
|
||||
* every uncached tag, waits once for all of them, then tears them down.
|
||||
* Pass {@link Duration#ZERO} (or {@code null}) to use the worker default
|
||||
* (1000 ms). Per-tag failures
|
||||
* appear as {@link BulkReadResult} entries with {@code wasSuccessful == false};
|
||||
* this method does not throw for per-tag MXAccess failures.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the items
|
||||
* @param tagAddresses the tag addresses to read
|
||||
* @param timeout per-tag snapshot timeout (zero or null = worker default)
|
||||
* @param timeout snapshot deadline shared by all tags in the command (zero or null = worker default)
|
||||
* @return a per-tag {@link BulkReadResult} list
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws NullPointerException if {@code tagAddresses} is {@code null}
|
||||
@@ -1033,6 +1042,67 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a {@code Ping} diagnostic command to the session's worker.
|
||||
*
|
||||
* @param message free-form text carried in the command
|
||||
* @return the raw command reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
*/
|
||||
public MxCommandReply ping(String message) {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_PING)
|
||||
.setPing(PingCommand.newBuilder().setMessage(message == null ? "" : message))
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the worker-side state of this session.
|
||||
*
|
||||
* @return the reported {@link SessionState}
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
*/
|
||||
public SessionState getSessionState() {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_GET_SESSION_STATE)
|
||||
.setGetSessionState(GetSessionStateCommand.getDefaultInstance())
|
||||
.build())
|
||||
.getSessionState()
|
||||
.getState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the worker process and MXAccess COM identity.
|
||||
*
|
||||
* @return the {@link WorkerInfoReply} (process id, version, ProgID, CLSID)
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
*/
|
||||
public WorkerInfoReply getWorkerInfo() {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_GET_WORKER_INFO)
|
||||
.setGetWorkerInfo(GetWorkerInfoCommand.getDefaultInstance())
|
||||
.build())
|
||||
.getWorkerInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls queued events from the worker without opening a stream. The reply
|
||||
* is bounded server-side and may carry fewer than {@code maxEvents}; drain
|
||||
* iteratively until an empty list is returned.
|
||||
*
|
||||
* @param maxEvents the maximum number of events to return
|
||||
* @return the drained events, possibly empty
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
*/
|
||||
public List<MxEvent> drainEvents(int maxEvents) {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_DRAIN_EVENTS)
|
||||
.setDrainEvents(DrainEventsCommand.newBuilder().setMaxEvents(maxEvents))
|
||||
.build())
|
||||
.getDrainEvents()
|
||||
.getEventsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a pre-built {@link MxCommand} for this session and returns the raw
|
||||
* reply, attaching a freshly generated client correlation id.
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
import io.grpc.stub.ClientResponseObserver;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class MxEventStreamTests {
|
||||
@Test
|
||||
void overflowKeepsRetainedEventsThenSurfacesError() {
|
||||
MxEventStream stream = new MxEventStream(3);
|
||||
RecordingCall call = new RecordingCall();
|
||||
ClientResponseObserver<StreamEventsRequest, MxEvent> observer = stream.observer();
|
||||
observer.beforeStart(call);
|
||||
|
||||
for (long i = 1; i <= 5; i++) {
|
||||
observer.onNext(MxEvent.newBuilder().setWorkerSequence(i).build());
|
||||
}
|
||||
|
||||
assertTrue(call.cancelCount.get() >= 1, "overflow cancels the call");
|
||||
assertEquals(1L, stream.next().getWorkerSequence());
|
||||
assertEquals(2L, stream.next().getWorkerSequence());
|
||||
assertEquals(3L, stream.next().getWorkerSequence());
|
||||
MxGatewayException error = assertThrows(MxGatewayException.class, stream::hasNext);
|
||||
assertTrue(error.getMessage().contains("overflowed"));
|
||||
assertFalse(stream.hasNext());
|
||||
}
|
||||
|
||||
@Test
|
||||
void interruptCancelsCallAndRestoresInterruptFlag() {
|
||||
MxEventStream stream = new MxEventStream(4);
|
||||
RecordingCall call = new RecordingCall();
|
||||
stream.observer().beforeStart(call);
|
||||
|
||||
Thread.currentThread().interrupt();
|
||||
try {
|
||||
assertThrows(io.grpc.StatusRuntimeException.class, stream::hasNext);
|
||||
assertTrue(Thread.currentThread().isInterrupted(), "interrupt flag restored");
|
||||
assertEquals(1, call.cancelCount.get(), "call cancelled on interrupt");
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingCall extends ClientCallStreamObserver<StreamEventsRequest> {
|
||||
final AtomicInteger cancelCount = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public void cancel(String message, Throwable cause) {
|
||||
cancelCount.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnReadyHandler(Runnable onReadyHandler) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void request(int count) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMessageCompression(boolean enable) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAutoInboundFlowControl() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(StreamEventsRequest value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -51,7 +51,10 @@ import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.DrainEventsReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SessionStateReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WorkerInfoReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||
@@ -727,6 +730,63 @@ final class MxGatewayClientSessionTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void diagnosticsHelpersBuildTypedCommandsAndDecodeReplies() throws Exception {
|
||||
AtomicReference<MxCommandRequest> last = new AtomicReference<>();
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
last.set(request);
|
||||
MxCommandReply.Builder reply = MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok());
|
||||
switch (request.getCommand().getKind()) {
|
||||
case MX_COMMAND_KIND_GET_SESSION_STATE -> reply.setSessionState(
|
||||
SessionStateReply.newBuilder().setState(SessionState.SESSION_STATE_READY));
|
||||
case MX_COMMAND_KIND_GET_WORKER_INFO -> reply.setWorkerInfo(
|
||||
WorkerInfoReply.newBuilder().setWorkerProcessId(99).setWorkerVersion("1.2.3"));
|
||||
case MX_COMMAND_KIND_DRAIN_EVENTS -> reply.setDrainEvents(DrainEventsReply.newBuilder()
|
||||
.addEvents(MxEvent.newBuilder().setWorkerSequence(5)));
|
||||
default -> { }
|
||||
}
|
||||
responseObserver.onNext(reply.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "existing-session");
|
||||
|
||||
MxCommandReply ping = session.ping("hello");
|
||||
assertEquals(MxCommandKind.MX_COMMAND_KIND_PING, ping.getKind());
|
||||
assertEquals("hello", last.get().getCommand().getPing().getMessage());
|
||||
|
||||
assertEquals(SessionState.SESSION_STATE_READY, session.getSessionState());
|
||||
assertEquals(MxCommandKind.MX_COMMAND_KIND_GET_SESSION_STATE, last.get().getCommand().getKind());
|
||||
|
||||
WorkerInfoReply info = session.getWorkerInfo();
|
||||
assertEquals(99, info.getWorkerProcessId());
|
||||
assertEquals("1.2.3", info.getWorkerVersion());
|
||||
|
||||
List<MxEvent> events = session.drainEvents(10);
|
||||
assertEquals(1, events.size());
|
||||
assertEquals(5L, events.get(0).getWorkerSequence());
|
||||
assertEquals(10, last.get().getCommand().getDrainEvents().getMaxEvents());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventQueueCapacityOptionDefaultsAndValidates() {
|
||||
assertEquals(1024, MxGatewayClientOptions.builder().endpoint("x").build().eventQueueCapacity());
|
||||
assertEquals(
|
||||
7, MxGatewayClientOptions.builder().endpoint("x").eventQueueCapacity(7).build().eventQueueCapacity());
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> MxGatewayClientOptions.builder().endpoint("x").eventQueueCapacity(0).build());
|
||||
}
|
||||
|
||||
private static TestGatewayService okInvokeService(AtomicReference<MxCommandRequest> commandRequest) {
|
||||
return new TestGatewayService() {
|
||||
@Override
|
||||
|
||||
+46
@@ -74,6 +74,52 @@ final class MxGatewayCredentialReplyTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmAsyncRedactsOperatorPasswordFromFailure() throws Exception {
|
||||
String serverName = "mxgw-java-ack-" + UUID.randomUUID();
|
||||
MxAccessGatewayGrpc.MxAccessGatewayImplBase service = new MxAccessGatewayGrpc.MxAccessGatewayImplBase() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request,
|
||||
StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> observer) {
|
||||
observer.onNext(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.newBuilder()
|
||||
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
|
||||
.setMessage("auth failed for operator1 password " + CREDENTIAL))
|
||||
.build());
|
||||
observer.onCompleted();
|
||||
}
|
||||
};
|
||||
Server server = InProcessServerBuilder.forName(serverName)
|
||||
.directExecutor().addService(service).build().start();
|
||||
ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build();
|
||||
try (MxGatewayClient client = new MxGatewayClient(
|
||||
channel,
|
||||
MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process").apiKey("").plaintext(true)
|
||||
.callTimeout(Duration.ofSeconds(5)).build())) {
|
||||
var request = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.newBuilder()
|
||||
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||
.setOperatorUser("operator1")
|
||||
.setOperatorPassword(CREDENTIAL)
|
||||
.build();
|
||||
|
||||
java.util.concurrent.ExecutionException async = assertThrows(
|
||||
java.util.concurrent.ExecutionException.class,
|
||||
() -> client.acknowledgeAlarmAsync(request).get());
|
||||
assertTrue(async.getCause() instanceof MxGatewayException, String.valueOf(async.getCause()));
|
||||
assertFalse(async.getCause().getMessage().contains(CREDENTIAL));
|
||||
assertFalse(((MxGatewayCommandException) async.getCause()).protocolStatus().getMessage()
|
||||
.contains(CREDENTIAL));
|
||||
|
||||
MxGatewayException sync = assertThrows(MxGatewayException.class, () -> client.acknowledgeAlarm(request));
|
||||
assertFalse(sync.getMessage().contains(CREDENTIAL));
|
||||
} finally {
|
||||
channel.shutdownNow();
|
||||
server.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticateUserMissingPayloadThrowsMalformedReply() throws Exception {
|
||||
MxCommandReply reply = loadReply("authenticate-user.missing-payload.reply.json");
|
||||
|
||||
Binary file not shown.
@@ -111,9 +111,40 @@ MXAccess commands and preserve raw replies on typed command exceptions.
|
||||
For alarms, the client exposes `GatewayClient.query_active_alarms` (one-shot
|
||||
snapshot), `GatewayClient.stream_alarms` (async generator yielding alarm-feed
|
||||
messages from the gateway's central monitor), and
|
||||
`GatewayClient.acknowledge_alarm` (ack by alarm reference, optional comment
|
||||
and ack target). Cancel the surrounding task or `aclose()` the iterator to
|
||||
terminate the stream.
|
||||
`GatewayClient.acknowledge_alarm` (ack by alarm reference with an optional
|
||||
comment). The ack is made as the operator: set `operator_user` (required) and
|
||||
`operator_password` (may be empty) and optionally `operator_domain` on
|
||||
`AcknowledgeAlarmRequest`. The gateway authenticates the operator and writes the
|
||||
comment to the alarm's `AckMsg`, so the alarm history records the operator. The
|
||||
password is redacted from any raised error. The operator's full name is not sent by the client and is not propagated to MXAccess (`operator_full_name` on the worker commands is accepted but ignored); the Historian takes it from the authenticated Galaxy account.
|
||||
|
||||
Cancel the surrounding task or `aclose()` an alarm iterator to terminate the
|
||||
stream.
|
||||
|
||||
`ActiveAlarmSnapshot.from_truncated_snapshot` reports that the record came from
|
||||
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
||||
alarms, and the gateway suspended its absence-implies-cleared inference for that
|
||||
poll. Treat the set as possibly incomplete rather than reconciling deletions
|
||||
from it. It is set-level degraded status, not a comment on the record's own
|
||||
fidelity, and is distinct from `degraded` (the subtag fallback provider).
|
||||
|
||||
`stream_alarms` also carries that completeness verdict at feed level, as a
|
||||
message whose `WhichOneof("payload")` is `snapshot_status` and whose
|
||||
`snapshot_status.truncated` is true while the monitor's cached set derives from
|
||||
a truncated fetch. One arrives at stream open (after the `provider_status`
|
||||
frame, before the cached `active_alarm` frames) so a late joiner learns the
|
||||
current verdict, then one on every verdict change — including the clearing frame
|
||||
sent when the gateway's alarm monitor restarts and drops a truncated verdict.
|
||||
Track it if you need set completeness on a live feed without polling
|
||||
`query_active_alarms`.
|
||||
|
||||
`mxgw-py stream-alarms` writes one JSON row as each feed message arrives. The
|
||||
default `--max-messages 0` is unbounded; use a positive value for a finite
|
||||
sample, remembering that the provider-status and snapshot-status preamble both
|
||||
count toward it. `--timeout` ends the command after an idle wait and the stream
|
||||
is closed in every exit path. Protobuf JSON omits false-valued scalar defaults,
|
||||
so healthy/clearing frames appear as `{"providerStatus": {}}` and
|
||||
`{"snapshotStatus": {}}`; absent means false.
|
||||
|
||||
Canceling a Python task cancels the client-side gRPC call or stream wait. It
|
||||
does not abort an in-flight MXAccess COM call inside the worker process.
|
||||
@@ -431,8 +462,8 @@ mxgw-py register --session-id <id> --client-name python-client --json
|
||||
mxgw-py add-item --session-id <id> --server-handle 1 --item Object.Attribute --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-alarms --max-messages 1 --json
|
||||
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
mxgw-py stream-alarms --max-messages 4 --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
|
||||
```
|
||||
|
||||
@@ -467,6 +498,15 @@ mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGAT
|
||||
|
||||
## Installing from the Gitea PyPI Feed
|
||||
|
||||
**The published `0.2.0` artifact cannot connect.** Its `connect()` built the
|
||||
`grpc.aio` channel inside `asyncio.to_thread`, and a `grpc.aio` channel binds
|
||||
to the event loop current on the *constructing* thread — so every
|
||||
non-stub connection raised `RuntimeError: There is no current event loop in
|
||||
thread`. Fixed in-tree by commit `a8f86b5` (`resolve_channel_security` now
|
||||
runs off-loop, `create_channel` runs on-loop). The fix is superseded by
|
||||
`0.2.1`; do not install `0.2.0` from the feed below — use `0.2.1` or later,
|
||||
or install from source at a commit at or after `a8f86b5`.
|
||||
|
||||
The client publishes to the internal Gitea PyPI feed:
|
||||
|
||||
````bash
|
||||
@@ -483,3 +523,10 @@ a `~/.netrc` entry or `PIP_INDEX_URL=https://<user>:<token>@gitea.dohertylan.com
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
||||
- [Python Client Detailed Design](./PythonClientDesign.md)
|
||||
|
||||
## Typed diagnostics
|
||||
|
||||
`Session.ping(message)`, `get_session_state()`, `get_worker_info()`, and
|
||||
`drain_events(max_events)` wrap the worker diagnostic commands. `drain_events`
|
||||
returns a list that may be shorter than `max_events`; call it until it returns
|
||||
an empty list.
|
||||
|
||||
@@ -36,7 +36,11 @@ function Resolve-Python {
|
||||
function Assert-GrpcioToolsVersion {
|
||||
param([string]$Python)
|
||||
|
||||
$version = (& $Python -c 'import grpc_tools; from importlib.metadata import version; print(version("grpcio-tools"))').Trim()
|
||||
# Windows PowerShell 5.1 strips embedded double quotes when passing an argument to a native
|
||||
# exe (pwsh 7 does not), so a Python literal quoted with " " here would arrive at Python as
|
||||
# print(version(grpcio-tools)) -> NameError. Use single quotes for the Python string literal
|
||||
# inside this double-quoted PowerShell string so the quoting survives on both hosts.
|
||||
$version = (& $Python -c "import grpc_tools; from importlib.metadata import version; print(version('grpcio-tools'))").Trim()
|
||||
if ($version -ne $PinnedGrpcioToolsVersion) {
|
||||
throw "grpcio-tools $version is installed, but regeneration is pinned to $PinnedGrpcioToolsVersion. " +
|
||||
"Install the pin (python -m pip install 'grpcio-tools==$PinnedGrpcioToolsVersion') before regenerating, " +
|
||||
|
||||
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "zb-mom-ww-mxaccess-gateway-client"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
description = "Async Python client for MXAccess Gateway."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
import grpc
|
||||
|
||||
from .auth import merge_metadata
|
||||
from .errors import ensure_protocol_success, map_rpc_error
|
||||
from .errors import MxGatewayError, ensure_protocol_success, map_rpc_error
|
||||
from .generated import mxaccess_gateway_pb2 as pb
|
||||
from .generated import mxaccess_gateway_pb2_grpc as pb_grpc
|
||||
from .options import ClientOptions, create_channel, resolve_channel_security
|
||||
@@ -148,17 +148,23 @@ class GatewayClient:
|
||||
self,
|
||||
request: pb.AcknowledgeAlarmRequest,
|
||||
) -> pb.AcknowledgeAlarmReply:
|
||||
"""Acknowledge an active MXAccess alarm condition through the gateway.
|
||||
"""Acknowledge an alarm as the operator.
|
||||
|
||||
The gateway authenticates the request against the API key's
|
||||
``invoke:alarm-ack`` scope and forwards the acknowledge to the worker's
|
||||
MXAccess session; the resulting native ``MxStatus`` is returned in the
|
||||
reply. Acks are idempotent — re-acking an already-acked condition is a
|
||||
no-op at the MxAccess layer.
|
||||
The gateway authenticates ``operator_user`` with ``operator_password`` and
|
||||
writes the comment to the alarm's ``AckMsg``, so the alarm history records
|
||||
the operator. The password is redacted from any raised error.
|
||||
"""
|
||||
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
||||
return reply
|
||||
try:
|
||||
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
||||
return reply
|
||||
except MxGatewayError as error:
|
||||
if request.operator_password:
|
||||
# Imported lazily: session.py imports this module at its tail.
|
||||
from .session import _redact_error
|
||||
|
||||
_redact_error(error, [request.operator_password])
|
||||
raise
|
||||
|
||||
def query_active_alarms(
|
||||
self,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import Any
|
||||
|
||||
from .auth import redact_secret
|
||||
from .errors import MalformedReplyError, MxGatewayError, ensure_mxaccess_success
|
||||
@@ -447,8 +448,10 @@ class Session:
|
||||
``OnDataChange`` value for any tag that is already advised (``was_cached =
|
||||
True``) without modifying the existing subscription, and falls back to
|
||||
a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
|
||||
otherwise. ``timeout_ms`` bounds the per-tag wait in the snapshot case;
|
||||
pass ``0`` to use the worker default (1000 ms).
|
||||
otherwise. ``timeout_ms`` is one shared deadline for the whole command,
|
||||
not a per-tag budget: the worker advises every uncached tag, waits once
|
||||
for all of them, then tears them down. Pass ``0`` to use the worker
|
||||
default (1000 ms).
|
||||
"""
|
||||
if tag_addresses is None:
|
||||
raise TypeError("tag_addresses is required")
|
||||
@@ -837,6 +840,55 @@ class Session:
|
||||
)
|
||||
return reply.activate.status
|
||||
|
||||
async def ping(self, message: str = "", *, correlation_id: str = "") -> pb.MxCommandReply:
|
||||
"""Invoke the worker `Ping` diagnostic and return its reply."""
|
||||
return await self.invoke(
|
||||
pb.MxCommand(kind=pb.MX_COMMAND_KIND_PING, ping=pb.PingCommand(message=message)),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def get_session_state(self, *, correlation_id: str = "") -> int:
|
||||
"""Return the worker-reported `SessionState` enum value."""
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_GET_SESSION_STATE,
|
||||
get_session_state=pb.GetSessionStateCommand(),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return reply.session_state.state
|
||||
|
||||
async def get_worker_info(self, *, correlation_id: str = "") -> pb.WorkerInfoReply:
|
||||
"""Return worker process id, version, and MXAccess ProgID/CLSID."""
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_GET_WORKER_INFO,
|
||||
get_worker_info=pb.GetWorkerInfoCommand(),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return reply.worker_info
|
||||
|
||||
async def drain_events(
|
||||
self,
|
||||
max_events: int = 0,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.MxEvent]:
|
||||
"""Drain queued worker events (diagnostic).
|
||||
|
||||
A reply may carry fewer than *max_events* (server count and frame-size
|
||||
caps); call repeatedly until an empty list is returned.
|
||||
"""
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_DRAIN_EVENTS,
|
||||
drain_events=pb.DrainEventsCommand(max_events=max_events),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.drain_events.events)
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
*,
|
||||
@@ -943,22 +995,26 @@ def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
|
||||
error.raw_reply = _redact_reply(error.raw_reply, scrubbed)
|
||||
|
||||
|
||||
def _redact_reply(reply: pb.MxCommandReply, secrets: Sequence[str]) -> pb.MxCommandReply:
|
||||
def _redact_reply(reply: Any, secrets: Sequence[str]) -> Any:
|
||||
"""Return a deep copy of *reply* with credential text scrubbed from diagnostics.
|
||||
|
||||
Operates on a clone so the caller's original reply object is never mutated.
|
||||
Only the free-text diagnostic fields that can echo a client-supplied secret
|
||||
are scrubbed; the structured/enum fields the gateway itself sets are left as-is.
|
||||
Works for any reply message (``MxCommandReply``, ``AcknowledgeAlarmReply``, ...):
|
||||
each field is scrubbed only when the message type declares it.
|
||||
"""
|
||||
clone = type(reply)()
|
||||
clone.CopyFrom(reply)
|
||||
if clone.protocol_status.message:
|
||||
fields = clone.DESCRIPTOR.fields_by_name
|
||||
if "protocol_status" in fields and clone.protocol_status.message:
|
||||
clone.protocol_status.message = redact_secret(clone.protocol_status.message, secrets)
|
||||
if clone.diagnostic_message:
|
||||
if "diagnostic_message" in fields and clone.diagnostic_message:
|
||||
clone.diagnostic_message = redact_secret(clone.diagnostic_message, secrets)
|
||||
for status in clone.statuses:
|
||||
if status.diagnostic_text:
|
||||
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
|
||||
if "statuses" in fields:
|
||||
for status in clone.statuses:
|
||||
if status.diagnostic_text:
|
||||
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
|
||||
return clone
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Package version information."""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
@@ -36,6 +36,7 @@ MAX_AGGREGATE_EVENTS = 10_000
|
||||
#: CLI (CLI-45) so one exported variable drives the same operator workflow in all
|
||||
#: five languages.
|
||||
DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD"
|
||||
DEFAULT_OPERATOR_PASSWORD_ENV = "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
|
||||
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
||||
|
||||
@@ -391,7 +392,7 @@ def unsubscribe_bulk(**kwargs: Any) -> None:
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--items", required=True, help="Comma-separated MXAccess tag addresses.")
|
||||
@click.option("--timeout-ms", default=0, type=int, show_default=True,
|
||||
help="Per-tag snapshot timeout in milliseconds. 0 = worker default.")
|
||||
help="Snapshot deadline in milliseconds, shared by all tags in the command. 0 = worker default (1000 ms).")
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def read_bulk(**kwargs: Any) -> None:
|
||||
@@ -511,16 +512,15 @@ def stream_events(**kwargs: Any) -> None:
|
||||
@main.command("stream-alarms")
|
||||
@gateway_options
|
||||
@click.option("--filter-prefix", default="", help="Alarm-reference prefix filter.")
|
||||
@click.option("--max-messages", default=1, type=int, show_default=True)
|
||||
@click.option("--max-messages", default=0, type=int, show_default=True, help="Maximum rows to emit; 0 means unbounded.")
|
||||
@click.option("--timeout", default=5.0, type=float, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def stream_alarms(**kwargs: Any) -> None:
|
||||
"""Stream a bounded number of messages from the gateway's central alarm feed."""
|
||||
"""Stream messages from the gateway's central alarm feed as they arrive."""
|
||||
|
||||
_run(
|
||||
_run_streaming(
|
||||
_stream_alarms(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
)
|
||||
|
||||
@@ -529,16 +529,30 @@ def stream_alarms(**kwargs: Any) -> None:
|
||||
@gateway_options
|
||||
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
|
||||
@click.option("--comment", default="", help="Acknowledgement comment.")
|
||||
@click.option("--operator", default="", help="Operator user name.")
|
||||
@click.option("--operator", required=True, help="Operator user name (recorded in the alarm history).")
|
||||
@click.option("--operator-domain", default="", help="Operator Windows/Galaxy domain (optional).")
|
||||
@click.option(
|
||||
"--operator-password",
|
||||
default=None,
|
||||
help="Operator password. Prefer --operator-password-env so the secret is not visible on the command line.",
|
||||
)
|
||||
@click.option(
|
||||
"--operator-password-env",
|
||||
default=DEFAULT_OPERATOR_PASSWORD_ENV,
|
||||
show_default=True,
|
||||
help="Environment variable holding the operator password (an unset variable means an empty password).",
|
||||
)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def acknowledge_alarm(**kwargs: Any) -> None:
|
||||
"""Acknowledge an active MXAccess alarm condition (session-less)."""
|
||||
"""Acknowledge an active MXAccess alarm condition as the operator (session-less)."""
|
||||
|
||||
password = _resolve_operator_password(kwargs)
|
||||
kwargs["operator_password"] = password
|
||||
_run(
|
||||
_acknowledge_alarm(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
secrets=_secrets(kwargs) + [password],
|
||||
)
|
||||
|
||||
|
||||
@@ -837,6 +851,21 @@ async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"userId": user_id}
|
||||
|
||||
|
||||
def _resolve_operator_password(kwargs: dict[str, Any]) -> str:
|
||||
"""Resolve the alarm-ack operator password from --operator-password or its env var.
|
||||
|
||||
Unlike :func:`_resolve_password`, an unset or empty value is allowed: Galaxy
|
||||
accounts may have empty passwords. The value is never echoed; callers add it
|
||||
to the ``secrets`` redaction list.
|
||||
"""
|
||||
|
||||
env_name = kwargs.get("operator_password_env") or DEFAULT_OPERATOR_PASSWORD_ENV
|
||||
password = kwargs.get("operator_password")
|
||||
if password is None:
|
||||
password = os.environ.get(env_name, "")
|
||||
return password
|
||||
|
||||
|
||||
def _resolve_password(kwargs: dict[str, Any]) -> str:
|
||||
"""Resolve the authenticate-user password from --password or --password-env.
|
||||
|
||||
@@ -1119,19 +1148,39 @@ async def _stream_events(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"events": [_event_row(event) for event in events]}
|
||||
|
||||
|
||||
async def _stream_alarms(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
messages = await _collect_alarm_messages(
|
||||
client.stream_alarms(
|
||||
pb.StreamAlarmsRequest(
|
||||
client_correlation_id=kwargs["correlation_id"],
|
||||
alarm_filter_prefix=kwargs["filter_prefix"],
|
||||
),
|
||||
),
|
||||
max_messages=kwargs["max_messages"],
|
||||
timeout=kwargs["timeout"],
|
||||
async def _stream_alarms(**kwargs: Any) -> None:
|
||||
max_messages = int(kwargs["max_messages"])
|
||||
if max_messages > MAX_AGGREGATE_EVENTS:
|
||||
raise click.BadParameter(
|
||||
f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
|
||||
param_hint="--max-messages",
|
||||
)
|
||||
return {"messages": [_message_dict(message) for message in messages]}
|
||||
|
||||
async with await _connect(kwargs) as client:
|
||||
messages = client.stream_alarms(
|
||||
pb.StreamAlarmsRequest(
|
||||
client_correlation_id=kwargs["correlation_id"],
|
||||
alarm_filter_prefix=kwargs["filter_prefix"],
|
||||
),
|
||||
)
|
||||
iterator = messages.__aiter__()
|
||||
emitted = 0
|
||||
try:
|
||||
while max_messages == 0 or emitted < max_messages:
|
||||
try:
|
||||
message = await asyncio.wait_for(
|
||||
iterator.__anext__(),
|
||||
timeout=kwargs["timeout"],
|
||||
)
|
||||
except (StopAsyncIteration, asyncio.TimeoutError):
|
||||
break
|
||||
_emit(_message_dict(message), output_json=kwargs["output_json"])
|
||||
sys.stdout.flush()
|
||||
emitted += 1
|
||||
finally:
|
||||
close = getattr(iterator, "aclose", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
|
||||
|
||||
async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
|
||||
@@ -1142,6 +1191,8 @@ async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
|
||||
alarm_full_reference=kwargs["reference"],
|
||||
comment=kwargs["comment"],
|
||||
operator_user=kwargs["operator"],
|
||||
operator_domain=kwargs["operator_domain"],
|
||||
operator_password=kwargs["operator_password"],
|
||||
),
|
||||
)
|
||||
return _message_dict(reply)
|
||||
@@ -1489,6 +1540,17 @@ def _run(
|
||||
_emit(payload, output_json=output_json)
|
||||
|
||||
|
||||
def _run_streaming(
|
||||
awaitable: Awaitable[None],
|
||||
*,
|
||||
secrets: list[str | None],
|
||||
) -> None:
|
||||
try:
|
||||
asyncio.run(awaitable)
|
||||
except MxGatewayError as error:
|
||||
raise click.ClickException(redact_secret(str(error), secrets)) from error
|
||||
|
||||
|
||||
def _emit(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
@@ -1536,34 +1598,6 @@ async def _collect_events(
|
||||
return collected
|
||||
|
||||
|
||||
async def _collect_alarm_messages(
|
||||
messages: Any,
|
||||
*,
|
||||
max_messages: int,
|
||||
timeout: float,
|
||||
) -> list[pb.AlarmFeedMessage]:
|
||||
if max_messages > MAX_AGGREGATE_EVENTS:
|
||||
raise click.BadParameter(
|
||||
f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
|
||||
param_hint="--max-messages",
|
||||
)
|
||||
|
||||
collected: list[pb.AlarmFeedMessage] = []
|
||||
iterator = messages.__aiter__()
|
||||
try:
|
||||
while len(collected) < max_messages:
|
||||
collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
close = getattr(iterator, "aclose", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
return collected
|
||||
|
||||
|
||||
async def _collect_deploy_events(
|
||||
events: Any,
|
||||
*,
|
||||
|
||||
@@ -34,6 +34,8 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
comment="investigating",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
operator_domain="CORP",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -44,9 +46,68 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
|
||||
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert captured_request.comment == "investigating"
|
||||
assert captured_request.operator_user == "alice"
|
||||
assert captured_request.operator_password == "pw-secret"
|
||||
assert captured_request.operator_domain == "CORP"
|
||||
assert stub.acknowledge_alarm.metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_error_redacts_operator_password() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
stub.acknowledge_alarm.exception = FakeRpcError(
|
||||
grpc.StatusCode.UNAUTHENTICATED, "bad credentials pw-secret",
|
||||
)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(MxGatewayAuthenticationError) as excinfo:
|
||||
await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
),
|
||||
)
|
||||
|
||||
assert "pw-secret" not in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_protocol_failure_redacts_operator_password() -> None:
|
||||
from zb_mom_ww_mxgateway.errors import MxGatewayError
|
||||
|
||||
stub = FakeGatewayStub()
|
||||
stub.acknowledge_alarm.replies = [
|
||||
pb.AcknowledgeAlarmReply(
|
||||
protocol_status=pb.ProtocolStatus(
|
||||
code=pb.PROTOCOL_STATUS_CODE_INVALID_REQUEST,
|
||||
message="rejected credential pw-secret",
|
||||
),
|
||||
),
|
||||
]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(MxGatewayError) as excinfo:
|
||||
await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
),
|
||||
)
|
||||
|
||||
assert "pw-secret" not in str(excinfo.value)
|
||||
raw = excinfo.value.raw_reply
|
||||
assert isinstance(raw, pb.AcknowledgeAlarmReply)
|
||||
assert "pw-secret" not in raw.protocol_status.message
|
||||
assert "pw-secret" not in str(raw)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
|
||||
@@ -7,6 +7,7 @@ fixed source.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time as _time_module_ref
|
||||
@@ -296,6 +297,22 @@ class _AlarmFakeStream:
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
class _IncrementalAlarmFakeStream(_AlarmFakeStream):
|
||||
def __init__(self, messages: list[pb.AlarmFeedMessage], emitted: list[str]) -> None:
|
||||
super().__init__(messages)
|
||||
self._emitted = emitted
|
||||
self._yielded = 0
|
||||
|
||||
async def __anext__(self) -> pb.AlarmFeedMessage:
|
||||
if self._yielded > 0:
|
||||
assert len(self._emitted) >= self._yielded, "previous alarm row was buffered"
|
||||
if not self._messages:
|
||||
await asyncio.Event().wait()
|
||||
raise AssertionError("unreachable")
|
||||
self._yielded += 1
|
||||
return self._messages.pop(0)
|
||||
|
||||
|
||||
class _BulkFakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = replies
|
||||
@@ -667,8 +684,15 @@ def test_cli_write_bulk_happy_path(monkeypatch) -> None:
|
||||
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_BULK
|
||||
|
||||
|
||||
def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
|
||||
transitions = [
|
||||
def test_cli_stream_alarms_default_streams_status_cached_and_live_rows_incrementally(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
messages = [
|
||||
pb.AlarmFeedMessage(provider_status=pb.AlarmProviderStatus(degraded=False)),
|
||||
pb.AlarmFeedMessage(snapshot_status=pb.AlarmSnapshotStatus(truncated=False)),
|
||||
pb.AlarmFeedMessage(
|
||||
active_alarm=pb.ActiveAlarmSnapshot(alarm_full_reference="Tank01.Level.HiHi"),
|
||||
),
|
||||
pb.AlarmFeedMessage(
|
||||
transition=pb.OnAlarmTransitionEvent(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
@@ -676,7 +700,15 @@ def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
|
||||
),
|
||||
),
|
||||
]
|
||||
stream = _AlarmFakeStream(transitions)
|
||||
emitted: list[str] = []
|
||||
real_echo = cli_commands.click.echo
|
||||
|
||||
def recording_echo(message=None, **kwargs) -> None:
|
||||
emitted.append(str(message))
|
||||
real_echo(message, **kwargs)
|
||||
|
||||
monkeypatch.setattr(cli_commands.click, "echo", recording_echo)
|
||||
stream = _IncrementalAlarmFakeStream(messages, emitted)
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_alarm_stream(stream)
|
||||
_install_fake_connect(monkeypatch, stub)
|
||||
@@ -689,10 +721,8 @@ def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--plaintext",
|
||||
"--max-messages",
|
||||
"1",
|
||||
"--timeout",
|
||||
"5.0",
|
||||
"0.01",
|
||||
"--filter-prefix",
|
||||
"Tank01.",
|
||||
"--json",
|
||||
@@ -700,11 +730,48 @@ def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["messages"][0]["transition"]["alarmFullReference"] == "Tank01.Level.HiHi"
|
||||
rows = [json.loads(line) for line in result.output.splitlines()]
|
||||
assert [next(iter(row)) for row in rows] == [
|
||||
"providerStatus",
|
||||
"snapshotStatus",
|
||||
"activeAlarm",
|
||||
"transition",
|
||||
]
|
||||
assert stream.cancelled
|
||||
|
||||
|
||||
def test_cli_stream_alarms_positive_max_messages_remains_finite(monkeypatch) -> None:
|
||||
messages = [
|
||||
pb.AlarmFeedMessage(provider_status=pb.AlarmProviderStatus()),
|
||||
pb.AlarmFeedMessage(snapshot_status=pb.AlarmSnapshotStatus()),
|
||||
pb.AlarmFeedMessage(active_alarm=pb.ActiveAlarmSnapshot()),
|
||||
]
|
||||
stream = _AlarmFakeStream(messages)
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_alarm_stream(stream)
|
||||
_install_fake_connect(monkeypatch, stub)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"stream-alarms",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--plaintext",
|
||||
"--max-messages",
|
||||
"2",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
rows = [json.loads(line) for line in result.output.splitlines()]
|
||||
assert [next(iter(row)) for row in rows] == ["providerStatus", "snapshotStatus"]
|
||||
assert stream.cancelled
|
||||
|
||||
|
||||
def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
|
||||
monkeypatch.setenv("MXGW_TEST_OPPW", "pw-secret")
|
||||
stub = _BulkFakeStub()
|
||||
stub.acknowledge_alarm = _BulkFakeUnary(
|
||||
[
|
||||
@@ -732,12 +799,19 @@ def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
|
||||
"investigating",
|
||||
"--operator",
|
||||
"alice",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password-env",
|
||||
"MXGW_TEST_OPPW",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "pw-secret" not in result.output
|
||||
captured_request = stub.acknowledge_alarm.requests[0]
|
||||
assert captured_request.operator_domain == "CORP"
|
||||
assert captured_request.operator_password == "pw-secret"
|
||||
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert captured_request.comment == "investigating"
|
||||
assert captured_request.operator_user == "alice"
|
||||
|
||||
@@ -234,3 +234,61 @@ async def test_suspend_and_activate_return_status() -> None:
|
||||
)
|
||||
status = await session.activate(12, 34)
|
||||
assert status.category == pb.MX_STATUS_CATEGORY_OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_sends_message_and_returns_reply() -> None:
|
||||
session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_PING)])
|
||||
reply = await session.ping("hello")
|
||||
command = stub.invoke.requests[0].command
|
||||
assert command.kind == pb.MX_COMMAND_KIND_PING
|
||||
assert command.ping.message == "hello"
|
||||
assert reply.kind == pb.MX_COMMAND_KIND_PING
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_state_returns_state() -> None:
|
||||
session, stub = await _session_with(
|
||||
[
|
||||
_ok(
|
||||
pb.MX_COMMAND_KIND_GET_SESSION_STATE,
|
||||
session_state=pb.SessionStateReply(state=pb.SESSION_STATE_CLOSED),
|
||||
),
|
||||
],
|
||||
)
|
||||
state = await session.get_session_state()
|
||||
assert state == pb.SESSION_STATE_CLOSED
|
||||
assert stub.invoke.requests[0].command.kind == pb.MX_COMMAND_KIND_GET_SESSION_STATE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_worker_info_returns_info() -> None:
|
||||
session, stub = await _session_with(
|
||||
[
|
||||
_ok(
|
||||
pb.MX_COMMAND_KIND_GET_WORKER_INFO,
|
||||
worker_info=pb.WorkerInfoReply(worker_process_id=42, worker_version="1.2"),
|
||||
),
|
||||
],
|
||||
)
|
||||
info = await session.get_worker_info()
|
||||
assert info.worker_process_id == 42
|
||||
assert info.worker_version == "1.2"
|
||||
assert stub.invoke.requests[0].command.kind == pb.MX_COMMAND_KIND_GET_WORKER_INFO
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_events_sends_max_and_returns_events() -> None:
|
||||
session, stub = await _session_with(
|
||||
[
|
||||
_ok(
|
||||
pb.MX_COMMAND_KIND_DRAIN_EVENTS,
|
||||
drain_events=pb.DrainEventsReply(events=[pb.MxEvent(), pb.MxEvent()]),
|
||||
),
|
||||
],
|
||||
)
|
||||
events = await session.drain_events(max_events=5)
|
||||
assert len(events) == 2
|
||||
command = stub.invoke.requests[0].command
|
||||
assert command.kind == pb.MX_COMMAND_KIND_DRAIN_EVENTS
|
||||
assert command.drain_events.max_events == 5
|
||||
|
||||
Generated
+2
-2
@@ -590,7 +590,7 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "mxgw-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"futures-util",
|
||||
@@ -1490,7 +1490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zb-mom-ww-mxgateway-client"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zb-mom-ww-mxgateway-client"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2021"
|
||||
authors = ["Joseph Doherty"]
|
||||
description = "Async Rust client for the MxAccessGateway gRPC service, including a lazy-browse walker over the Galaxy Repository hierarchy."
|
||||
@@ -25,7 +25,7 @@ resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
authors = ["Joseph Doherty"]
|
||||
license = "Proprietary"
|
||||
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
@@ -40,7 +40,7 @@ prost-types = "0.13.5"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync", "time"] }
|
||||
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync", "time", "fs"] }
|
||||
tokio-stream = { version = "0.1.17", features = ["net"] }
|
||||
tonic = { version = "0.13.1", features = ["transport", "tls-ring", "tls-native-roots"] }
|
||||
tonic-build = "0.13.1"
|
||||
|
||||
+49
-9
@@ -28,11 +28,13 @@ tarball, where the rest of the mxaccessgw repo does not exist. The vendored
|
||||
copies are shipped in the published `.crate` via `Cargo.toml`'s `include`
|
||||
list, which is what makes the crate buildable standalone; they are build
|
||||
inputs only, never a second source of truth. **Refresh rule:** any commit
|
||||
that edits a Contracts proto (`mxaccess_gateway.proto`, `mxaccess_worker.proto`,
|
||||
`galaxy_repository.proto`) must copy the changed file(s) into
|
||||
`clients/rust/protos/` in that same commit — `scripts/check-codegen.ps1`
|
||||
Check 3 fails the build on byte drift between the vendored copies and the
|
||||
canonical Contracts protos. `tonic`/`prost` bindings are generated into
|
||||
that edits *or adds* a Contracts proto (today `mxaccess_gateway.proto`,
|
||||
`mxaccess_worker.proto`, `galaxy_repository.proto`) must copy the changed or
|
||||
new file(s) into `clients/rust/protos/` in that same commit —
|
||||
`scripts/check-codegen.ps1` Check 3 fails the build on byte drift between the
|
||||
vendored copies and the canonical Contracts protos, and equally on a canonical
|
||||
proto that has no vendored copy (which an in-repo build cannot catch, since
|
||||
`build.rs` reads the canonical directory here). `tonic`/`prost` bindings are generated into
|
||||
Cargo build output. `src/generated.rs` declares the Rust modules that
|
||||
include those generated files. `src/generated` remains reserved for checked-in
|
||||
generator output if the crate later changes to source-tree generation.
|
||||
@@ -75,8 +77,9 @@ cargo run -p mxgw-cli -- register --session-id <session-id> --client-name mxgw-r
|
||||
cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --item TestChildObject.TestInt --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-alarms --session-id <session-id> --max-messages 1 --json
|
||||
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 4 --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
|
||||
```
|
||||
|
||||
@@ -121,8 +124,33 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer
|
||||
`close_session_raw`, `invoke_raw`, `stream_events`, `query_active_alarms`,
|
||||
`stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms`
|
||||
returns an `AlarmFeedStream` async stream of alarm-feed messages and
|
||||
shares the gateway's central alarm monitor with every other client. The
|
||||
session helpers keep MXAccess handles visible:
|
||||
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 operator's full name is not sent by the client and is not propagated to MXAccess (`operator_full_name` on the worker commands is accepted but ignored); the Historian takes it from the authenticated Galaxy account. The password is scrubbed from any returned error's `Display`/`Debug`.
|
||||
|
||||
The `from_truncated_snapshot` field on `ActiveAlarmSnapshot` reports that the record came from
|
||||
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
||||
alarms, and the gateway suspended its absence-implies-cleared inference for that
|
||||
poll. Treat the set as possibly incomplete rather than reconciling deletions
|
||||
from it. It is set-level degraded status, not a comment on the record's own
|
||||
fidelity, and is distinct from `degraded` (the subtag fallback provider).
|
||||
|
||||
`AlarmFeedStream` also carries that completeness verdict at feed level, as an
|
||||
`alarm_feed_message::Payload::SnapshotStatus` frame whose `truncated` field is
|
||||
true while the monitor's cached set derives from a truncated fetch. One arrives
|
||||
at stream open (after the `ProviderStatus` frame, before the cached
|
||||
`ActiveAlarm` frames) so a late joiner learns the current verdict, then one on
|
||||
every verdict change — including the clearing frame sent when the gateway's
|
||||
alarm monitor restarts and drops a truncated verdict. Track it if you need set
|
||||
completeness on a live feed without polling `query_active_alarms`.
|
||||
|
||||
Unlike the protobuf-JSON formatters used by the other CLIs, the Rust CLI's
|
||||
hand-built JSON status rows always include `"degraded": false` and
|
||||
`"truncated": false`. This deliberate representational difference is
|
||||
documented in the cross-language smoke matrix; both shapes mean the same thing.
|
||||
|
||||
The session helpers keep MXAccess handles visible:
|
||||
|
||||
```rust
|
||||
let session = client.open_session(request).await?;
|
||||
@@ -150,6 +178,18 @@ redaction. Per-item bulk failures are reported inside each result entry
|
||||
`invoke_raw` / `client.invoke_raw` escape hatch performs neither check and
|
||||
returns the unvalidated reply.
|
||||
|
||||
### Diagnostic commands
|
||||
|
||||
`Session` wraps the worker diagnostic commands as typed calls: `ping(message)`
|
||||
(returns `()` on success), `get_session_state()` (returns `SessionState`),
|
||||
`get_worker_info()` (returns `WorkerInfoReply`: pid, version, MXAccess
|
||||
ProgID/CLSID) and `drain_events(max_events)` (returns `Vec<MxEvent>`; the reply
|
||||
is bounded server-side, so drain until an empty result). A reply missing its
|
||||
expected payload surfaces as `Error::MalformedReply`.
|
||||
|
||||
TLS note: `ClientOptions::with_ca_file` is read asynchronously (`tokio::fs`)
|
||||
during `connect`, so connecting no longer blocks the executor thread.
|
||||
|
||||
## Event Streaming And Reconnect-Replay Gaps
|
||||
|
||||
`session.events()` / `session.events_after(after_worker_sequence)` (and the
|
||||
|
||||
@@ -174,6 +174,8 @@ alarm monitor — no worker session is opened — so any number of clients may
|
||||
attach. Dropping the stream cancels the gRPC call cooperatively.
|
||||
`acknowledge_alarm` is idempotent at the MxAccess layer; the returned
|
||||
`AcknowledgeAlarmReply` carries the native MxStatus from the worker.
|
||||
`operator_user` is required; `operator_password` may be empty and is scrubbed from any
|
||||
returned error via `Error::redact_secrets`.
|
||||
|
||||
## Galaxy Repository
|
||||
|
||||
@@ -346,7 +348,7 @@ mxgw write-secured-bulk ...
|
||||
mxgw write-secured2-bulk ...
|
||||
mxgw stream-events --session-id <id> --json
|
||||
mxgw stream-alarms [--filter-prefix <prefix>] [--max-events <n>]
|
||||
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] [--operator <user>]
|
||||
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] --operator <user> [--operator-domain <domain>] [--operator-password-env <VAR>=MXGATEWAY_OPERATOR_PASSWORD] [--operator-password <pw>]
|
||||
mxgw bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>]
|
||||
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
||||
mxgw batch
|
||||
|
||||
@@ -215,7 +215,7 @@ enum Command {
|
||||
server_handle: i32,
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
items: Vec<String>,
|
||||
/// Per-tag snapshot timeout in milliseconds. `0` uses the worker default (1000 ms).
|
||||
/// Snapshot deadline in milliseconds, shared by all tags in the command. `0` uses the worker default (1000 ms).
|
||||
#[arg(long, default_value_t = 0)]
|
||||
timeout_ms: u32,
|
||||
#[arg(long)]
|
||||
@@ -374,8 +374,19 @@ enum Command {
|
||||
reference: String,
|
||||
#[arg(long, default_value = "")]
|
||||
comment: String,
|
||||
#[arg(long, default_value = "")]
|
||||
/// Galaxy account of the acknowledging operator (required; the gateway
|
||||
/// rejects an empty operator so the real operator is recorded).
|
||||
#[arg(long)]
|
||||
operator: String,
|
||||
/// Galaxy domain of the operator; empty authenticates the bare user name.
|
||||
#[arg(long, default_value = "")]
|
||||
operator_domain: String,
|
||||
/// Operator password (prefer `--operator-password-env`; empty is allowed).
|
||||
#[arg(long)]
|
||||
operator_password: Option<String>,
|
||||
/// Environment variable holding the operator password.
|
||||
#[arg(long, default_value = "MXGATEWAY_OPERATOR_PASSWORD")]
|
||||
operator_password_env: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
@@ -1081,8 +1092,16 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
reference,
|
||||
comment,
|
||||
operator,
|
||||
operator_domain,
|
||||
operator_password,
|
||||
operator_password_env,
|
||||
json,
|
||||
} => {
|
||||
// An empty password is allowed (Galaxy accounts may have one), so
|
||||
// an unset flag and env resolve to an empty string.
|
||||
let operator_password = operator_password
|
||||
.or_else(|| env::var(&operator_password_env).ok())
|
||||
.unwrap_or_default();
|
||||
let client = connect(connection).await?;
|
||||
let reply = client
|
||||
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
||||
@@ -1090,6 +1109,8 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
alarm_full_reference: reference,
|
||||
comment,
|
||||
operator_user: operator,
|
||||
operator_password,
|
||||
operator_domain,
|
||||
})
|
||||
.await?;
|
||||
print_acknowledge_alarm_reply(&reply, json);
|
||||
@@ -2231,7 +2252,7 @@ fn event_value_to_json(value: &ProtoMxValue) -> Value {
|
||||
}
|
||||
|
||||
/// Render a streamed [`AlarmFeedMessage`] as a terse one-line summary that
|
||||
/// distinguishes the four `payload` oneof cases.
|
||||
/// distinguishes the five `payload` oneof cases.
|
||||
fn alarm_feed_message_summary(message: &AlarmFeedMessage) -> String {
|
||||
match &message.payload {
|
||||
Some(alarm_feed_message::Payload::ActiveAlarm(snapshot)) => {
|
||||
@@ -2259,6 +2280,9 @@ fn alarm_feed_message_summary(message: &AlarmFeedMessage) -> String {
|
||||
status.reason
|
||||
)
|
||||
}
|
||||
Some(alarm_feed_message::Payload::SnapshotStatus(status)) => {
|
||||
format!("snapshot-status truncated={}", status.truncated)
|
||||
}
|
||||
None => "(empty)".to_owned(),
|
||||
}
|
||||
}
|
||||
@@ -2308,6 +2332,11 @@ fn alarm_feed_message_to_json(message: &AlarmFeedMessage) -> Value {
|
||||
})),
|
||||
}
|
||||
}),
|
||||
Some(alarm_feed_message::Payload::SnapshotStatus(status)) => json!({
|
||||
"snapshotStatus": {
|
||||
"truncated": status.truncated,
|
||||
}
|
||||
}),
|
||||
None => Value::Null,
|
||||
}
|
||||
}
|
||||
@@ -2763,6 +2792,17 @@ mod tests {
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledge_alarm_requires_operator() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"mxgw",
|
||||
"acknowledge-alarm",
|
||||
"--reference",
|
||||
"Tank01.Level.HiHi",
|
||||
]);
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledge_alarm_requires_reference() {
|
||||
let parsed = Cli::try_parse_from(["mxgw", "acknowledge-alarm"]);
|
||||
@@ -2961,4 +3001,23 @@ mod tests {
|
||||
assert_eq!(provider["reason"], "alarmmgr unavailable");
|
||||
assert_eq!(provider["since"]["seconds"], 1_777_995_000_i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alarm_feed_snapshot_status_renders_in_summary_and_json() {
|
||||
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||
alarm_feed_message, AlarmFeedMessage, AlarmSnapshotStatus,
|
||||
};
|
||||
|
||||
let message = AlarmFeedMessage {
|
||||
payload: Some(alarm_feed_message::Payload::SnapshotStatus(
|
||||
AlarmSnapshotStatus { truncated: true },
|
||||
)),
|
||||
};
|
||||
|
||||
let summary = super::alarm_feed_message_summary(&message);
|
||||
assert_eq!(summary, "snapshot-status truncated=true");
|
||||
|
||||
let value = super::alarm_feed_message_to_json(&message);
|
||||
assert_eq!(value["snapshotStatus"]["truncated"], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ message MxCommand {
|
||||
WriteSecuredBulkCommand write_secured_bulk = 41;
|
||||
WriteSecured2BulkCommand write_secured2_bulk = 42;
|
||||
ReadBulkCommand read_bulk = 43;
|
||||
UpdateAlarmWatchListCommand update_alarm_watch_list = 44;
|
||||
PingCommand ping = 100;
|
||||
GetSessionStateCommand get_session_state = 101;
|
||||
GetWorkerInfoCommand get_worker_info = 102;
|
||||
@@ -174,6 +175,7 @@ enum MxCommandKind {
|
||||
MX_COMMAND_KIND_WRITE_SECURED_BULK = 32;
|
||||
MX_COMMAND_KIND_WRITE_SECURED2_BULK = 33;
|
||||
MX_COMMAND_KIND_READ_BULK = 34;
|
||||
MX_COMMAND_KIND_UPDATE_ALARM_WATCH_LIST = 35;
|
||||
MX_COMMAND_KIND_PING = 100;
|
||||
MX_COMMAND_KIND_GET_SESSION_STATE = 101;
|
||||
MX_COMMAND_KIND_GET_WORKER_INFO = 102;
|
||||
@@ -344,8 +346,8 @@ enum AlarmProviderMode {
|
||||
// Subscription expression follows the canonical
|
||||
// `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The
|
||||
// worker spins up a wnwrapConsumer-backed subscription on its STA on
|
||||
// first call; subsequent calls are an error (use UnsubscribeAlarms then
|
||||
// SubscribeAlarms to reconfigure).
|
||||
// first call; subsequent calls are an error. Use UpdateAlarmWatchList to
|
||||
// replace the standby targets without restarting the primary subscription.
|
||||
message SubscribeAlarmsCommand {
|
||||
string subscription_expression = 1;
|
||||
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
|
||||
@@ -354,6 +356,17 @@ message SubscribeAlarmsCommand {
|
||||
// alarmmgr mode; in subtag mode it bounds what the consumer can observe.
|
||||
repeated AlarmSubtagTarget watch_list = 3;
|
||||
AlarmFailoverConfig failover = 4;
|
||||
// True when auto mode should retain subtag-fallback capability even if the
|
||||
// gateway's initial discovery produced an empty watch list. False leaves an
|
||||
// empty list as an intentional alarm-manager-only subscription.
|
||||
bool fallback_enabled = 5;
|
||||
}
|
||||
|
||||
// Replace the live alarm subscription's subtag standby targets. The worker
|
||||
// constructs and attaches the replacement before swapping it into service, so
|
||||
// a setup failure leaves the existing standby and active provider unchanged.
|
||||
message UpdateAlarmWatchListCommand {
|
||||
repeated AlarmSubtagTarget watch_list = 1;
|
||||
}
|
||||
|
||||
// Tear down the worker's alarm consumer. No-op if no subscription is
|
||||
@@ -378,10 +391,13 @@ message AlarmFailoverConfig {
|
||||
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
|
||||
}
|
||||
|
||||
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
||||
// recorded atomically with the ack transition in the alarm-history log.
|
||||
// The reply's hresult / native_status surfaces AVEVA's
|
||||
// AlarmAckByGUID return code.
|
||||
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
|
||||
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
|
||||
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
|
||||
// AuthenticateUser as the operator, so the alarm history records the real
|
||||
// operator. The reply's hresult / native_status is 0 when the write was
|
||||
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
|
||||
// the OnWriteComplete status detail.
|
||||
message AcknowledgeAlarmCommand {
|
||||
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
|
||||
string alarm_guid = 1;
|
||||
@@ -390,6 +406,10 @@ message AcknowledgeAlarmCommand {
|
||||
string operator_node = 4;
|
||||
string operator_domain = 5;
|
||||
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
|
||||
@@ -401,9 +421,7 @@ message QueryActiveAlarmsCommand {
|
||||
|
||||
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
|
||||
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
|
||||
// be resolved to a GUID directly. The worker invokes
|
||||
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
|
||||
// history path as AlarmAckByGUID.
|
||||
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
|
||||
message AcknowledgeAlarmByNameCommand {
|
||||
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
|
||||
// may contain dots; the gateway-side parser splits on the first dot
|
||||
@@ -418,6 +436,10 @@ message AcknowledgeAlarmByNameCommand {
|
||||
string operator_node = 6;
|
||||
string operator_domain = 7;
|
||||
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 {
|
||||
@@ -537,11 +559,14 @@ message MxCommandReply {
|
||||
MxValue return_value = 6;
|
||||
// Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
|
||||
// WRITE_SECURED2 replies the worker holds the reply for a bounded window
|
||||
// (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
|
||||
// (default 1.5 s, MxGateway:Worker:WriteCompletionWaitMilliseconds) waiting for
|
||||
// the matching MXAccess OnWriteComplete callback and copies its status rows
|
||||
// here, so statuses[0] carries the real MXAccess commit outcome (success OR
|
||||
// failure) while protocol_status/hresult still describe command acceptance
|
||||
// only. Empty statuses on a write reply means the completion did not arrive
|
||||
// only. Callback correlation is recency-based per item handle: after the
|
||||
// bounded wait, a sequential write's callback supersedes a timed-out
|
||||
// predecessor, and a later stale callback for that predecessor is ignored.
|
||||
// Empty statuses on a write reply means the completion did not arrive
|
||||
// within the window — the write is unconfirmed, not failed. Correlation is
|
||||
// best-effort per (server_handle, item_handle): MXAccess's callback carries
|
||||
// no transaction id, so concurrent writes to the same item within the
|
||||
@@ -709,11 +734,11 @@ message DrainEventsReply {
|
||||
// Reply payload for AcknowledgeAlarmCommand AND
|
||||
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
|
||||
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
|
||||
// dedicated by-name reply case. Surfaces AVEVA's native ack return
|
||||
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
|
||||
// by-name arm); 0 means success. The MxCommandReply's hresult field
|
||||
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
|
||||
// AckMsg write was applied or sent but unconfirmed; otherwise the
|
||||
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
|
||||
// carries the same value and is preferred for protocol consumers —
|
||||
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
|
||||
// this payload exists so GatewayAlarmMonitor
|
||||
// can echo native_status into AcknowledgeAlarmReply.hresult without
|
||||
// unpacking the outer envelope.
|
||||
message AcknowledgeAlarmReplyPayload {
|
||||
@@ -726,6 +751,13 @@ message AcknowledgeAlarmReplyPayload {
|
||||
// stream.
|
||||
message QueryActiveAlarmsReplyPayload {
|
||||
repeated ActiveAlarmSnapshot snapshots = 1;
|
||||
// True when the provider fetch backing this reply came back holding the
|
||||
// per-fetch cap (MxGateway:Alarms:MaxAlarmsPerFetch). The reply may then omit
|
||||
// active alarms, and the worker suspends its absence-implies-Clear inference
|
||||
// for that poll — so a reference missing from `snapshots` is not evidence the
|
||||
// alarm cleared. Carried on the payload as well as per-record because a
|
||||
// truncated fetch that filters down to zero records still has to say so.
|
||||
bool snapshot_truncated = 2;
|
||||
}
|
||||
|
||||
message MxEvent {
|
||||
@@ -932,6 +964,16 @@ message ActiveAlarmSnapshot {
|
||||
// OnAlarmTransitionEvent.source_provider; always ALARMMGR or SUBTAG on the
|
||||
// wire (never UNSPECIFIED).
|
||||
AlarmProviderMode source_provider = 15;
|
||||
// True when the provider fetch that produced this snapshot hit the per-fetch
|
||||
// cap: the snapshot set may omit active alarms, and the worker suspended its
|
||||
// absence-implies-Clear inference for that poll. Says nothing about THIS
|
||||
// record's fidelity — the record is as accurate as any other; it flags that
|
||||
// the set it belongs to is possibly incomplete. QueryActiveAlarms returns a
|
||||
// bare `stream ActiveAlarmSnapshot` with no envelope message, so a per-record
|
||||
// boolean is the only additive way to carry set-level degraded status on that
|
||||
// RPC. Distinct from `degraded`, which is about the subtag fallback provider.
|
||||
// Additive (proto3): clients that ignore it deserialize the stream unchanged.
|
||||
bool from_truncated_snapshot = 16;
|
||||
}
|
||||
|
||||
enum AlarmConditionState {
|
||||
@@ -954,6 +996,15 @@ message AcknowledgeAlarmRequest {
|
||||
// Operator principal performing the acknowledgement. The lmxopcua side
|
||||
// resolves this from the OPC UA session prior to invoking the RPC.
|
||||
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 {
|
||||
@@ -964,8 +1015,7 @@ message AcknowledgeAlarmReply {
|
||||
ProtocolStatus protocol_status = 3;
|
||||
// Native ack return code echoed from the worker. The worker carries the
|
||||
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
|
||||
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
|
||||
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
|
||||
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
|
||||
// ack-outcome field for the public RPC. Absent only when the worker reply
|
||||
// omitted the value entirely (a protocol violation).
|
||||
optional int32 hresult = 4;
|
||||
@@ -986,9 +1036,13 @@ message StreamAlarmsRequest {
|
||||
string alarm_filter_prefix = 2;
|
||||
}
|
||||
|
||||
// One message on the StreamAlarms feed. The stream opens with one
|
||||
// `active_alarm` per currently-active alarm, then a single
|
||||
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||
// One message on the StreamAlarms feed. The stream opens with a
|
||||
// `provider_status` (the current provider mode), then a `snapshot_status`
|
||||
// (whether the cached active set may be incomplete), then one `active_alarm`
|
||||
// per currently-active alarm, then a single `snapshot_complete`. After that
|
||||
// come the live frames: a `transition` for every subsequent alarm change,
|
||||
// interleaved with a further `provider_status` on each failover/failback and a
|
||||
// further `snapshot_status` on each change of the truncation verdict.
|
||||
message AlarmFeedMessage {
|
||||
oneof payload {
|
||||
// Part of the initial active-alarm snapshot (ConditionRefresh).
|
||||
@@ -1001,6 +1055,10 @@ message AlarmFeedMessage {
|
||||
// Provider-mode status. Emitted once on stream open and again on every
|
||||
// failover/failback so late joiners learn the current mode immediately.
|
||||
AlarmProviderStatus provider_status = 4;
|
||||
// Snapshot-completeness status. Emitted once on stream open and again on
|
||||
// every change of the truncation verdict, so late joiners learn whether the
|
||||
// feed's active-alarm set may be incomplete.
|
||||
AlarmSnapshotStatus snapshot_status = 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,6 +1069,23 @@ message AlarmProviderStatus {
|
||||
google.protobuf.Timestamp since = 4;
|
||||
}
|
||||
|
||||
// Feed-level snapshot-completeness status. Emitted once on StreamAlarms open
|
||||
// (after the initial provider_status frame, before the cached active_alarm
|
||||
// frames) so late joiners learn the current verdict, and again on every change
|
||||
// of the truncation verdict — when a reconcile reports a different verdict, and
|
||||
// when the gateway's alarm monitor restarts and drops a truncated verdict with
|
||||
// the cache generation it described (feed subscribers outlive that monitor
|
||||
// session, so they are sent the clearing frame). Mirrors the per-record
|
||||
// ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live
|
||||
// consumers can reason about completeness without polling QueryActiveAlarms.
|
||||
message AlarmSnapshotStatus {
|
||||
// True while the monitor's cached active-alarm set derives from a truncated
|
||||
// (capped) worker fetch — the set may be missing alarms. Distinct from
|
||||
// provider degradation (AlarmProviderStatus.degraded), which describes the
|
||||
// fidelity of the records rather than the completeness of the set.
|
||||
bool truncated = 1;
|
||||
}
|
||||
|
||||
message MxStatusProxy {
|
||||
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
|
||||
// (a 16-bit signed value in the COM struct, widened to int32 on the
|
||||
|
||||
@@ -187,7 +187,7 @@ impl GatewayClient {
|
||||
})?;
|
||||
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
||||
|
||||
if let Some(tls) = build_tls_config(&options)? {
|
||||
if let Some(tls) = build_tls_config(&options).await? {
|
||||
endpoint = endpoint.tls_config(tls)?;
|
||||
}
|
||||
|
||||
@@ -327,6 +327,9 @@ impl GatewayClient {
|
||||
/// MXAccess session; the resulting native MxStatus is returned in the
|
||||
/// reply. Acks are idempotent at the MxAccess layer.
|
||||
///
|
||||
/// `operator_user` is required; `operator_password` may be empty. The
|
||||
/// password is scrubbed from any returned error's `Display`/`Debug`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but
|
||||
@@ -336,13 +339,19 @@ impl GatewayClient {
|
||||
&self,
|
||||
request: AcknowledgeAlarmRequest,
|
||||
) -> Result<AcknowledgeAlarmReply, Error> {
|
||||
let secret = request.operator_password.clone();
|
||||
let mut client = self.inner.clone();
|
||||
let response = client
|
||||
.acknowledge_alarm(self.unary_request(request))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
||||
Ok(reply)
|
||||
let result = async {
|
||||
let response = client
|
||||
.acknowledge_alarm(self.unary_request(request))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
||||
Ok(reply)
|
||||
}
|
||||
.await;
|
||||
// The operator password must never surface in a returned error.
|
||||
result.map_err(|error: Error| error.redact_secrets(&[secret.as_str()]))
|
||||
}
|
||||
|
||||
/// Open the server-streaming `QueryActiveAlarms` RPC — the gateway's
|
||||
|
||||
@@ -314,6 +314,68 @@ impl std::fmt::Display for MxAccessError {
|
||||
|
||||
impl std::error::Error for MxAccessError {}
|
||||
|
||||
impl Error {
|
||||
/// Scrub exact caller-supplied secrets (for example an operator password)
|
||||
/// from the error's rendered text and any wrapped `tonic::Status`, so that
|
||||
/// neither `Display` nor `Debug` can reveal them. Empty secrets are ignored.
|
||||
#[must_use]
|
||||
pub fn redact_secrets(self, secrets: &[&str]) -> Self {
|
||||
let scrub = |text: &str| {
|
||||
let mut out = text.to_owned();
|
||||
for secret in secrets.iter().filter(|secret| !secret.is_empty()) {
|
||||
out = out.replace(*secret, "<redacted>");
|
||||
}
|
||||
out
|
||||
};
|
||||
let scrub_status = |status: Box<tonic::Status>| {
|
||||
Box::new(tonic::Status::new(status.code(), scrub(status.message())))
|
||||
};
|
||||
match self {
|
||||
Self::Authentication { message, status } => Self::Authentication {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Authorization { message, status } => Self::Authorization {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Timeout { message, status } => Self::Timeout {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Cancelled { message, status } => Self::Cancelled {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Unavailable { message, status } => Self::Unavailable {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Status(status) => Self::Status(scrub_status(status)),
|
||||
Self::ProtocolStatus {
|
||||
operation,
|
||||
code,
|
||||
message,
|
||||
} => Self::ProtocolStatus {
|
||||
operation,
|
||||
code,
|
||||
message: scrub(&message),
|
||||
},
|
||||
Self::MalformedReply { detail } => Self::MalformedReply {
|
||||
detail: scrub(&detail),
|
||||
},
|
||||
Self::InvalidArgument { name, detail } => Self::InvalidArgument {
|
||||
name,
|
||||
detail: scrub(&detail),
|
||||
},
|
||||
Self::MxAccess(error) => Self::MxAccess(Box::new(
|
||||
error.with_secrets(secrets.iter().map(|secret| (*secret).to_owned()).collect()),
|
||||
)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tonic::Status> for Error {
|
||||
fn from(status: tonic::Status) -> Self {
|
||||
let message = redact_credentials(status.message());
|
||||
@@ -461,6 +523,14 @@ fn redact_credentials(message: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn redact_secrets_scrubs_status_display_and_debug() {
|
||||
let error: super::Error = tonic::Status::internal("boom pw-secret").into();
|
||||
let error = error.redact_secrets(&["pw-secret", ""]);
|
||||
assert!(!format!("{error:?}").contains("pw-secret"));
|
||||
assert!(!error.to_string().contains("pw-secret"));
|
||||
}
|
||||
|
||||
use tonic::{Code, Status};
|
||||
|
||||
use super::{ensure_mxaccess_success, Error};
|
||||
|
||||
@@ -182,7 +182,7 @@ impl GalaxyClient {
|
||||
})?;
|
||||
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
||||
|
||||
if let Some(tls) = build_tls_config(&options)? {
|
||||
if let Some(tls) = build_tls_config(&options).await? {
|
||||
endpoint = endpoint.tls_config(tls)?;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ pub mod mxaccess_gateway {
|
||||
/// `mxaccess_gateway.v1` package — the v1 wire contract surfaced by the
|
||||
/// gateway to language clients.
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
#![allow(clippy::large_enum_variant, clippy::result_large_err)]
|
||||
// tonic's generated server traits use async_trait, whose expansion adds
|
||||
// redundant must_use attributes to Future returns under Rust 1.99.
|
||||
#![allow(clippy::double_must_use)]
|
||||
|
||||
tonic::include_proto!("mxaccess_gateway.v1");
|
||||
}
|
||||
@@ -24,7 +27,7 @@ pub mod mxaccess_worker {
|
||||
/// `mxaccess_worker.v1` package — frame and envelope types used inside
|
||||
/// the named-pipe transport between gateway and worker.
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
#![allow(clippy::large_enum_variant, clippy::result_large_err)]
|
||||
|
||||
tonic::include_proto!("mxaccess_worker.v1");
|
||||
}
|
||||
@@ -35,7 +38,10 @@ pub mod galaxy_repository {
|
||||
/// `galaxy_repository.v1` package — types for the Galaxy hierarchy
|
||||
/// discovery and deploy-event watch RPCs.
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
#![allow(clippy::large_enum_variant, clippy::result_large_err)]
|
||||
// tonic's generated server traits use async_trait, whose expansion adds
|
||||
// redundant must_use attributes to Future returns under Rust 1.99.
|
||||
#![allow(clippy::double_must_use)]
|
||||
|
||||
tonic::include_proto!("galaxy_repository.v1");
|
||||
}
|
||||
|
||||
+28
-15
@@ -3,7 +3,6 @@
|
||||
//! chain of `with_*` setters; the `Debug` impl redacts the API key.
|
||||
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -235,7 +234,9 @@ pub(crate) fn tls_trust_decision(options: &ClientOptions) -> TlsTrustDecision {
|
||||
/// rejects the configuration early with an actionable error. The strict opt-in
|
||||
/// instead loads the system trust roots so a certificate chaining to an
|
||||
/// already-trusted root validates.
|
||||
pub(crate) fn build_tls_config(options: &ClientOptions) -> Result<Option<ClientTlsConfig>, Error> {
|
||||
pub(crate) async fn build_tls_config(
|
||||
options: &ClientOptions,
|
||||
) -> Result<Option<ClientTlsConfig>, Error> {
|
||||
let decision = tls_trust_decision(options);
|
||||
if decision == TlsTrustDecision::None {
|
||||
return Ok(None);
|
||||
@@ -248,10 +249,13 @@ pub(crate) fn build_tls_config(options: &ClientOptions) -> Result<Option<ClientT
|
||||
match decision {
|
||||
TlsTrustDecision::PinnedCa => {
|
||||
let ca_file = options.ca_file().expect("PinnedCa implies a CA file");
|
||||
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
||||
endpoint: options.endpoint().to_owned(),
|
||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
||||
})?;
|
||||
let certificate =
|
||||
tokio::fs::read(ca_file)
|
||||
.await
|
||||
.map_err(|source| Error::InvalidEndpoint {
|
||||
endpoint: options.endpoint().to_owned(),
|
||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
||||
})?;
|
||||
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
||||
}
|
||||
TlsTrustDecision::SystemRoots => {
|
||||
@@ -334,11 +338,11 @@ mod tests {
|
||||
assert!(!debug.contains("mxgw_secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_needs_no_tls() {
|
||||
#[tokio::test]
|
||||
async fn plaintext_needs_no_tls() {
|
||||
let options = ClientOptions::new("http://127.0.0.1:5000").with_plaintext(true);
|
||||
assert_eq!(tls_trust_decision(&options), TlsTrustDecision::None);
|
||||
assert!(build_tls_config(&options).unwrap().is_none());
|
||||
assert!(build_tls_config(&options).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -349,8 +353,8 @@ mod tests {
|
||||
assert_eq!(tls_trust_decision(&options), TlsTrustDecision::PinnedCa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_without_ca_uses_system_roots() {
|
||||
#[tokio::test]
|
||||
async fn strict_without_ca_uses_system_roots() {
|
||||
// Regression for Client.Rust-031: strict verification with no pinned CA
|
||||
// must verify against the system trust roots, not produce a config with
|
||||
// zero trust anchors. The trust decision proves roots are consulted; the
|
||||
@@ -365,15 +369,24 @@ mod tests {
|
||||
"strict-no-CA must request the system trust roots"
|
||||
);
|
||||
assert!(
|
||||
build_tls_config(&options).unwrap().is_some(),
|
||||
build_tls_config(&options).await.unwrap().is_some(),
|
||||
"strict-no-CA must build a usable TLS config"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lenient_without_ca_is_rejected() {
|
||||
#[tokio::test]
|
||||
async fn lenient_without_ca_is_rejected() {
|
||||
let options = ClientOptions::new("https://127.0.0.1:5000").with_plaintext(false);
|
||||
assert_eq!(tls_trust_decision(&options), TlsTrustDecision::RejectNoCa);
|
||||
assert!(build_tls_config(&options).is_err());
|
||||
assert!(build_tls_config(&options).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_pinned_ca_file_errors_without_blocking_read() {
|
||||
let options = ClientOptions::new("https://127.0.0.1:5000")
|
||||
.with_plaintext(false)
|
||||
.with_ca_file("/nonexistent/definitely-missing-ca.pem");
|
||||
let error = build_tls_config(&options).await.unwrap_err();
|
||||
assert!(error.to_string().contains("failed to read CA file"));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user