Compare commits

..

33 Commits

Author SHA1 Message Date
Joseph Doherty 42b0037376 Dashboard: replace inline fully-qualified type refs with @using
ApiKeysPage and GalaxyPage referenced ApiKeyConstraints and
GalaxyRepositoryOptions by full namespace inside @code. Add the
appropriate @using directive at the top of each file and let the
short type names resolve through that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:51:00 -04:00
Joseph Doherty de7639a3e9 Dashboard: fix 500 on / from duplicate endpoint mapping
GatewayApplication.MapGatewayEndpoints registered MapGet("/", ...)
that redirected to /health/live. After the dashboard added a Blazor
component at @page "/", both endpoints matched GET / and the matcher
threw AmbiguousMatchException, surfacing as a 500. The redirect
predated the dashboard home page — drop it so / lands on Overview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:48:43 -04:00
Joseph Doherty 8738735f0d clients: document StreamAlarms + AcknowledgeAlarm in each README
Each client's README now covers the alarms surface in both the SDK
section (StreamAlarms / AcknowledgeAlarm beside the existing
QueryActiveAlarms entry, with the streaming-cancellation note) and
the CLI examples (stream-alarms / acknowledge-alarm invocations
mirroring the in-tree implementations across .NET, Go, Rust, Python,
and Java).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:40:23 -04:00
Joseph Doherty e80f3c70b6 docs: cover admin dashboard actions + API key Delete
Update the design docs so they match the implemented Admin-only
dashboard surface. GatewayDashboardDesign now documents the Close
session / Kill worker controls and the new Delete action on revoked
API keys, plus the ConfirmDialog gate for every destructive action.
Sessions.md adds the SessionManager.KillWorkerAsync entry alongside
CloseSessionAsync and explains the immediate-kill semantics. Authentication.md adds the IApiKeyAdminStore.DeleteAsync write path
and the dashboard-delete-key audit event. DashboardInterfaceDesign
drops the "read-only until admin workflows have a separate design"
line in favor of the confirm-before-act invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:35:25 -04:00
Joseph Doherty 24cc5fd0f0 Dashboard: delete revoked API keys + confirm Rotate/Revoke/Delete
Add IApiKeyAdminStore.DeleteAsync that only deletes already-revoked
rows (active keys must be revoked first so the revoke event lands in
the audit log before the row disappears) and a matching admin-gated
DashboardApiKeyManagementService.DeleteAsync. ApiKeysPage now shows
Delete on revoked rows in place of the old "No actions" stub, and
Rotate/Revoke/Delete all route through ConfirmDialog so each
destructive action requires an explicit confirmation step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:30:30 -04:00
Joseph Doherty c5153d68bb Dashboard: fix API keys page stuck on "Loading"
ApiKeysPage.OnInitializedAsync overrode the base without chaining, so
DashboardPageBase's Snapshot seed and hub connect never ran and the
page rendered the null-snapshot empty state forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:22:38 -04:00
Joseph Doherty 0e56b5befb Dashboard: confirm before Close session / Kill worker
Add a shared ConfirmDialog component and route Sessions, Workers, and
SessionDetails Close/Kill buttons through it. The dialog shows the
target session id and a color-matched confirm button (yellow Close,
red Kill); Cancel dismisses without invoking the admin service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:17:32 -04:00
Joseph Doherty c5e7479ee4 Dashboard: admin-only Close session / Kill worker
Add IDashboardSessionAdminService (Admin-role gate, friendly errors,
audit logging) wrapping a new ISessionManager.KillWorkerAsync that
skips graceful shutdown and cleans up registry/metrics. Sessions,
Workers, and SessionDetails pages render Close / Kill buttons only
when CanManage; the service re-checks the role on every call so
forged clicks return Unauthenticated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:10:32 -04:00
Joseph Doherty 8a0c59d7e8 Java client: port stream-alarms and acknowledge-alarm
Adds the session-less alarm CLI subcommands to the Java CLI. stream-alarms
attaches to the gateway's central alarm feed (--filter-prefix, --limit, --json
— NDJSON, one AlarmFeedMessage per line); acknowledge-alarm is a unary ack
(--reference required, --comment, --operator). streamAlarms joins
queryActiveAlarms on MxGatewayClient and uses a new
MxGatewayAlarmFeedSubscription cancellable handle. Batch dispatch re-enters the
picocli command line per stdin line, so registering the two new subcommands
suffices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:46:03 -04:00
Joseph Doherty 828e3e6cf6 Python client: port stream-alarms and acknowledge-alarm
Adds the session-less alarm CLI subcommands to mxgw-py. stream-alarms reads a
bounded slice of the gateway's central alarm feed (--filter-prefix,
--max-messages, --timeout, --json; aggregate `{messages: [...]}`);
acknowledge-alarm is a unary ack (--reference required, --comment, --operator).
GatewayClient.stream_alarms joins query_active_alarms via a
_canceling_alarm_feed_iterator helper mirroring the existing
_canceling_active_alarms_iterator pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:54 -04:00
Joseph Doherty 7de4efeb02 Rust client: port stream-alarms and acknowledge-alarm + fix stream-events family + 8MB Windows stack
Adds the session-less alarm CLI subcommands to mxgw. stream-alarms attaches to
the gateway's central alarm feed (--filter-prefix, --max-events, --json/--jsonl;
aggregate shape `{messageCount, messages: [...]}`); acknowledge-alarm is a unary
ack (--reference required, --comment, --operator). stream_alarms joins
query_active_alarms on GatewayClient and re-exports AlarmFeedStream.

Also extends stream-events JSON to emit a full `events` array (itemHandle, value
projected to protojson-shaped `*Value` keys, etc.) instead of just `eventCount`,
matching the other four CLIs, and renders MxEvent.family as the protobuf enum
NAME (MX_EVENT_FAMILY_ON_WRITE_COMPLETE) rather than the raw i32 so the e2e
write round-trip can recognise the OnWriteComplete echo.

Adds clients/rust/.cargo/config.toml bumping the Windows main-thread stack to
8 MB via /STACK:8388608. clap-derive's Command enum (one variant per subcommand)
overflowed the default 1 MB stack in debug builds after the new variants
landed; release builds were unaffected but the e2e matrix runs Rust via
`cargo run` (debug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:46 -04:00
Joseph Doherty 6f0d142639 Go client: port stream-alarms and acknowledge-alarm
Adds the session-less alarm CLI subcommands to mxgw-go. stream-alarms attaches
to the gateway's central alarm feed (--filter-prefix, --limit, --json — NDJSON,
one AlarmFeedMessage per line); acknowledge-alarm is a unary ack (--reference
required, --comment, --operator). StreamAlarms joins QueryActiveAlarms on the
public Client and is wired through the existing batch dispatcher via runWithIO.
SDK type aliases for StreamAlarmsRequest / AlarmFeedMessage / StreamAlarmsClient
land alongside the existing alarm types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:32 -04:00
Joseph Doherty 11cc6715ed .NET client: port stream-alarms and acknowledge-alarm + fix stream-events OCE
Adds the session-less alarm CLI subcommands. stream-alarms attaches to the
gateway's central alarm feed (--filter-prefix, --max-events, --json/--jsonl);
acknowledge-alarm is a unary ack (--reference required, --comment, --operator).
StreamAlarmsAsync joins QueryActiveAlarmsAsync on MxGatewayClient and the
transport interface; the CLI client interface, adapter, and FakeGatewayTransport
follow.

Also fixes the OCE bug exposed by -VerifyWrite in the cross-language e2e:
StreamEventsAsync's await foreach now swallows OperationCanceledException when
the supplied cancellation token is the one that fired (graceful end-of-window),
and RunBatchAsync no longer excludes OCE from its outer catch — so a streaming
command that hits its --timeout reports a JSON error inside its EOR-delimited
record instead of killing the long-lived batch process.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:24 -04:00
Joseph Doherty f90bff01db Java client: port bulk read/write SDK methods + CLI subcommands
Final language in the bulk-CLI port wave. HEAD's MxGatewaySession had
only the subscribe-style bulks; this commit adds the value-bulks plus
matching picocli subcommands and a bench-read-bulk harness.

SDK (MxGatewaySession.java):
- List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries)
- List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries)
- List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries)
- List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries)
- List<BulkReadResult> readBulk(int serverHandle, List<String> tagAddresses, Duration timeout)

readBulk uses java.time.Duration for the timeout parameter (idiomatic
Java) and internally converts to the timeoutMs proto field;
Duration.ZERO / null both delegate to the worker default. Per-entry
secured user ids stay on each WriteSecured(2)BulkEntry to match the
proto's per-row shape.

CLI (MxGatewayCli.java):
- read-bulk / write-bulk / write2-bulk / write-secured-bulk /
  write-secured2-bulk as picocli @Command subcommands. Write families
  share value-parsing logic; gating of --current-user-id /
  --verifier-user-id / --timestamp matches the cross-language flag
  contract.
- bench-read-bulk: --iterations / --warmup loop with avg/min/max ms
  reporting plus a --json mode that emits the cross-language bench
  JSON schema.

A small fixture in MxGatewayCliTests.FakeSession adds stub
implementations of the five new interface methods so the test module
compiles.

Verification: gradle build BUILD SUCCESSFUL (4 tasks executed, all
tests pass); gradle :zb-mom-ww-mxgateway-cli:installDist BUILD
SUCCESSFUL. Manual smoke against live gateway on localhost:5120:
open-session → register → read-bulk cold (wasCached=false both tags)
→ subscribe-bulk → read-bulk warm (wasCached=true both tags) →
write-bulk int32 111,222 (both wasSuccessful=true) → write2-bulk
timestamped (both wasSuccessful=true) → write-secured-bulk and
write-secured2-bulk return per-entry MXAccess "Value does not fall
within the expected range" failures with the configured user/verifier
ids (0,0) — confirming the SDK does NOT throw on per-entry MXAccess
failures and surfaces them through BulkWriteResult exactly as the
.NET and Go ports do → bench-read-bulk iterations=20 avg=9.5 ms
last_success=2/2 cached=2/2 → close-session SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:50:34 -04:00
Joseph Doherty 6add4b4acc Python client: port bulk read/write SDK methods + CLI subcommands
Mirrors the .NET / Go ports of divergent branch commit f220908. HEAD's
Session class had only the subscribe-style bulks; this commit adds the
value-bulk SDK surface plus matching CLI subcommands and a
bench-read-bulk harness.

SDK (zb_mom_ww_mxgateway/session.py):
- async def write_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write2_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write_secured_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write_secured2_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def read_bulk(server_handle, tag_addresses, *, timeout_ms=0,
  correlation_id="") → list[pb.BulkReadResult]

All five reuse the existing _ensure_bulk_size validator and route
through the existing invoke() pipeline. read_bulk additionally enforces
timeout_ms >= 0.

CLI (zb_mom_ww_mxgateway_cli/commands.py):
- read-bulk / write-bulk / write2-bulk / write-secured-bulk /
  write-secured2-bulk registered as click @main.command(...). The
  write families share a _build_write_bulk_entries() helper that parses
  --item-handles and --values with a single --type, validates count
  match, converts via to_mx_value, and assembles the correct per-entry
  proto message.
- bench-read-bulk: opens its own session, subscribes to --bulk-size
  TestMachine_NNN.TestChangingInt tags, runs warmup then steady-state
  ReadBulk for --duration-seconds with time.perf_counter() latency
  capture, and emits the shared JSON schema (language, durationMs,
  totalCalls, successfulCalls, failedCalls, totalReadResults,
  cachedReadResults, callsPerSecond, latencyMs:{p50,p95,p99,max,mean})
  so scripts/bench-read-bulk.ps1 collates Python alongside the four
  other clients. _percentile_summary + linear-interpolation
  _percentile helper match the Go / .NET implementations.

to_mx_value is added to the existing values-module import line in
commands.py since the bulk-write commands need it.

Verification: python -m pip install -e . --quiet --no-deps; pytest
42/42 passing. Manual smoke against live gateway on localhost:5120:
open-session → register → subscribe-bulk on two
TestMachine_NNN.TestChangingInt tags (both wasSuccessful=true) →
read-bulk (both wasSuccessful=true / wasCached=true / int32 values
present) → close-session SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:50:10 -04:00
Joseph Doherty 325106920f Rust client: port BenchReadBulk subcommand + session.rs tightening
The bulk-write/read SDK methods (read_bulk, write_bulk, write2_bulk,
write_secured_bulk, write_secured2_bulk) and the matching clap
subcommands (ReadBulk, WriteBulk, Write2Bulk, WriteSecuredBulk,
WriteSecured2Bulk) were already on HEAD from a prior session — they
were the only bulk family that HEAD shipped before the .NET / Go /
Python / Java parallel ports. The one missing piece from the divergent
branch (commit f220908) was the BenchReadBulk benchmark harness.

mxgw-cli/src/main.rs adds:
- BenchReadBulk clap variant with flags --client-name,
  --duration-seconds, --warmup-seconds, --bulk-size, --tag-start,
  --tag-prefix, --tag-attribute, --timeout-ms, --json — defaults match
  the .NET and Go benches.
- run_bench_read_bulk(): open-session → register → subscribe_bulk on
  the synthesized TestMachine_NNN.TestChangingInt tags to populate the
  worker value cache → warmup → steady-state loop with per-call
  std::time::Instant capture → unsubscribe → close-session.
- BenchStats + LatencySummary structs and a percentile()
  helper (nearest-rank with linear interpolation, matching the Go and
  .NET implementations) so the cross-language JSON output is byte-for-
  byte comparable. JSON schema: language / command / endpoint /
  clientName / bulkSize / durationSeconds / warmupSeconds / durationMs
  / tags / totalCalls / successfulCalls / failedCalls /
  totalReadResults / cachedReadResults / callsPerSecond /
  latencyMs:{p50,p95,p99,max,mean}. scripts/bench-read-bulk.ps1 will
  pick up the Rust line on its next run.

session.rs picks up minor tightening tied to the bulk SDK methods that
were already in the file (per-entry validation paths, BulkReplyKind
dispatch coverage) — no public-surface change.

Verification: cargo build --workspace clean (the 2 pre-existing
options.rs missing_docs warnings remain — out of scope); cargo test
--workspace 34/34 passing; cargo clippy --workspace --all-targets has
only the 3 pre-existing tolerated warnings (enum_variant_names on
BulkReplyKind, missing_docs on options.rs, clone_on_copy on
galaxy.rs:282). Manual smoke against live gateway on localhost:5120:
read-bulk on two TestMachine tags returned wasCached=true,
wasSuccessful=true; bench-read-bulk --duration-seconds 2
--warmup-seconds 1 --bulk-size 2 --json ran 363 calls / 181.35 calls
per second / p50=5.3 ms / p99=7.8 ms / 726 of 726 cached reads, all
emitting valid JSON in the shared bench schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:50:09 -04:00
Joseph Doherty 8aaab82287 Go client: port bulk read/write SDK methods + CLI subcommands
Mirrors the .NET addition: HEAD's session.go had only the subscribe-style
bulks (AddItemBulk / AdviseItemBulk / RemoveItemBulk / UnAdviseItemBulk /
SubscribeBulk / UnsubscribeBulk). This commit ports the value-bulk SDK
surface and CLI subcommands from divergent branch commit f220908.

SDK (clients/go/mxgateway/session.go):
- WriteBulk(ctx, serverHandle int32, entries []*WriteBulkEntry)
- Write2Bulk(ctx, ..., entries []*Write2BulkEntry)
- WriteSecuredBulk(ctx, ..., entries []*WriteSecuredBulkEntry)
- WriteSecured2Bulk(ctx, ..., entries []*WriteSecured2BulkEntry)
- ReadBulk(ctx, serverHandle int32, tagAddresses []string, timeout time.Duration)
  → []*BulkReadResult

types.go gains public re-exports of the generated proto types
(WriteBulkCommand, WriteBulkEntry, Write2BulkCommand, Write2BulkEntry,
WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecured2BulkCommand,
WriteSecured2BulkEntry, ReadBulkCommand, BulkWriteReply, BulkWriteResult,
BulkReadReply, BulkReadResult) so external callers can construct entries
through the public `mxgateway` package without dipping into the internal
generated path.

CLI (clients/go/cmd/mxgw-go/main.go):
- read-bulk, write-bulk, write2-bulk, write-secured-bulk,
  write-secured2-bulk routed through runWithIO. write families share a
  runWriteBulkVariant helper that gates per-variant flags
  (--current-user-id, --verifier-user-id, --timestamp) so the
  Client.Go-015 flag-gating contract is preserved.
- bench-read-bulk: percentile + timing helpers; JSON output schema
  identical to the .NET / Rust / Python / Java benches.

parseInt32List was changed from panic-on-error to ([]int32, error) so
the new write-bulk commands surface parse errors gracefully; the
existing runUnsubscribeBulk caller is updated accordingly.

Verification: go build ./... + go vet ./... + go test ./... all clean.
Manual smoke against live gateway on localhost:5120: open-session →
register → subscribe-bulk on 3 TestMachine_NNN.TestChangingInt tags
(all wasSuccessful=true) → read-bulk (all wasSuccessful=true /
wasCached=true) → write-bulk int32 100/200/300 (all wasSuccessful=true)
→ close-session SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:49:33 -04:00
Joseph Doherty b3ae200b11 .NET client: port bulk read/write SDK methods + CLI subcommands
Adds the value-bulk SDK surface and CLI subcommands that lived on the
divergent branch (commit f220908) but were never merged into main.
HEAD's MxGatewaySession only had the subscribe-style bulks (AddItem /
Advise / Remove / UnAdvise / Subscribe / Unsubscribe). The proto
contract already defined ReadBulkCommand / WriteBulkCommand /
Write2BulkCommand / WriteSecuredBulkCommand / WriteSecured2BulkCommand
/ BulkReadReply / BulkWriteReply, so this is purely a client-side
addition.

SDK (MxGatewaySession.cs):
- WriteBulkAsync(serverHandle, IReadOnlyList<WriteBulkEntry>, ct)
- Write2BulkAsync(serverHandle, IReadOnlyList<Write2BulkEntry>, ct)
- WriteSecuredBulkAsync(serverHandle, IReadOnlyList<WriteSecuredBulkEntry>, ct)
- WriteSecured2BulkAsync(serverHandle, IReadOnlyList<WriteSecured2BulkEntry>, ct)
- ReadBulkAsync(serverHandle, IReadOnlyList<string> tagAddresses, TimeSpan timeout, ct)

Per-entry secured user ids live on each WriteSecured(2)BulkEntry — they
are NOT lifted to ctor args because the proto field shape allows distinct
ids per row.

CLI (MxGatewayClientCli.cs):
- read-bulk / write-bulk / write2-bulk / write-secured-bulk / write-secured2-bulk
  routed through the existing dispatch table, with --type, --values,
  --item-handles, --timeout-ms, --current-user-id, --verifier-user-id,
  --timestamp flags matching the cross-language CLI surface.
- bench-read-bulk benchmark harness: warmup + steady-state ReadBulk loop
  with p50/p95/p99/max/mean latency, emitting the shared JSON schema so
  scripts/bench-read-bulk.ps1 collates the .NET line alongside the four
  other clients.

The new subcommands flow through the existing batch dispatcher without
further changes.

Verification: dotnet build clean (0 warnings / 0 errors);
dotnet test 59/59 passing. Manual smoke against the live gateway
on localhost:5120: read-bulk returned 2 BulkReadResult entries with
wasSuccessful=true, wasCached=true; write-bulk on int32 returned
wasSuccessful=true; close-session returned SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:49:33 -04:00
Joseph Doherty 71d2c39f01 e2e: port batch subcommand to all five client CLIs
scripts/run-client-e2e-tests.ps1 expects each language CLI to expose a
`batch` subcommand that reads command lines from stdin, runs each
through the normal subcommand dispatch, writes the JSON result, then
a sentinel line `__MXGW_BATCH_EOR__`. The implementation lived on a
divergent branch (commit 6126099) that was never merged into main —
this commit ports the same protocol to HEAD's renamed CLIs so the
existing matrix script runs end-to-end.

The protocol:
  - one line of stdin = one full CLI invocation
  - successful output → stdout, then __MXGW_BATCH_EOR__
  - failure → {"error":"...","type":"error"} JSON on stdout, then
    __MXGW_BATCH_EOR__ (errors do NOT exit the loop)
  - empty line or EOF terminates the loop

Per-CLI additions:

  .NET: RunBatchAsync + per-line StringWriter capture, JSON error
    envelope when forceJsonErrors is true. Two new tests in
    MxGatewayClientCliTests covering the success and error paths.

  Go:   runBatch with bufio.Scanner, runs each line through the
    existing runWithIO switch with a buffered stdout writer. One new
    test pinning the EOR sentinel.

  Rust: new `Batch` variant on the clap Command enum, run_batch
    re-parses each line via Cli::try_parse_from. Two new tests in the
    inline mod tests block.

  Python: new `batch` click command in commands.py that uses
    CliRunner to dispatch each line; synthesises {"error",..."type"}
    JSON from click error messages when the captured output isn't
    already JSON-shaped. Three new tests in test_cli.py.

  Java: BatchCommand inner @Command with BufferedReader stdin loop,
    fresh commandLine() per dispatch with captured stdout/stderr
    PrintWriters; non-zero exit codes and uncaught exceptions both
    surface as JSON-error blocks. Two new tests.

Also fixes scripts/run-client-e2e-tests.ps1 line 705: the Python
invocation was still passing the old module name `mxgateway_cli` to
`python -m`; the client SDK rename in 397d3c5 moved it to
`zb_mom_ww_mxgateway_cli`. Without the fix the Python leg fails
with "No module named mxgateway_cli" before reaching open-session.

Verification: full matrix at the redeployed gateway (localhost:5120,
running ZB.MOM.WW.MxGateway.Server.exe / ZB.MOM.WW.MxGateway.Worker.exe)
with -SkipBulk -SkipReadWriteBulk -SkipParity -SkipAuth (those phases
exercise bulk read/write CLI subcommands that also live on the
divergent branch — porting those is a follow-up). All five clients
report `closed=true, addedItems=120, eventCount=5` and overall
`success=true`. Per-language unit tests pass:
  - dotnet: 59/59
  - go:     all packages clean
  - rust:   cargo test --workspace clean
  - python: 42/42
  - java:   gradle build SUCCESSFUL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:08:15 -04:00
Joseph Doherty a68f0cf222 code-review: regen README, all 21 open findings resolved
Closes the 2026-05-24 resolution sweep at HEAD `83eba4b`. All 21 open
findings from the d692232 re-review are now Resolved:

  Server          327e9c5  Server-031, -032, -038, -039, -040, -041, -042, -043
  Contracts       bd1d1f1  Contracts-016, -017
  Tests           d48099f  Tests-025, -026
  IntegrationTests 865c22a IntegrationTests-022, -023, -024
  Client.Java     10bd0c0  Client.Java-027, -028, -029, -030, -031
  Client.Rust     83eba4b  Client.Rust-021

Also fixes stale "Open findings" header counts in Client.Java and
IntegrationTests findings.md that survived the resolve passes
(the agents updated each finding's Status but missed the header
sum). `regen-readme.py --check` is now green.

Module status: 11 / 11 reviewed, 0 / 276 total open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:21:39 -04:00
Joseph Doherty 83eba4bec5 Resolve Client.Rust-021
Client.Rust-021 (Design adherence): RustClientDesign.md "Crate layout"
section now describes the actual flat workspace structure instead of
the aspirational nested form. The replacement text states that the
workspace root is clients/rust/, the top-level crate
zb-mom-ww-mxgateway-client is declared in clients/rust/Cargo.toml
directly, and crates/mxgw-cli/ is the sole [workspace.members] entry.
The accompanying tree lists the real files on disk (Cargo.toml,
Cargo.lock, build.rs, README.md, RustClientDesign.md,
src/{lib,client,session,galaxy,options,auth,error,value,version,generated}.rs
plus the src/generated/ tonic-build output dir, tests/, and
crates/mxgw-cli/).

Doc-only change. cargo build --workspace + cargo test --workspace clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:21:07 -04:00
Joseph Doherty 10bd0c0e4d Resolve Client.Java-027..031
Client.Java-027 (Documentation): Updated 17 Gradle task references in
clients/java/README.md (lines 37, 108-110, 160-161, 169-176, 186, 206,
221) and 3 in clients/java/JavaClientDesign.md from the retired short
subproject names to the canonical zb-mom-ww-mxgateway-client /
zb-mom-ww-mxgateway-cli names. Copy-pasting any documented command now
matches the subproject names declared in settings.gradle.

Client.Java-028 (Design adherence): Build-layout block in
JavaClientDesign.md lines 23-27 updated to show the actual package
paths com/zb/mom/ww/mxgateway/{client,cli}/ instead of the retired
com/dohertylan/mxgateway/{client,cli}/ paths.

Client.Java-029 (Documentation): README.md line 210 corrected from
"zb-mom-ww-mxgateway-cli/build/install/mxgateway-cli" to
"zb-mom-ww-mxgateway-cli/build/install/zb-mom-ww-mxgateway-cli" — Gradle
installDist produces a directory whose name matches the project name,
not the short suffix. The e2e script already used the correct path.

Client.Java-030 (Testing coverage): Added
queryActiveAlarmsForwardsRequestAndStreamsSnapshots to
MxGatewayClientSessionTests. The test pushes a QueryActiveAlarmsRequest
carrying session_id / client_correlation_id / alarm_filter_prefix
through an InProcessGateway + TestGatewayService and asserts the server
observed all three request fields, two ActiveAlarmSnapshots stream in
order, and onError is never called. TDD red→green confirmed via a
deliberately-wrong session_id assertion. The re-triage note in
Client.Java-030's resolution clarifies that the finding's reference to
"the existing acknowledgeAlarm test" was aspirational — the alarm RPC
surface had zero coverage before this commit.

Client.Java-031 (Conventions): README.md prose lines 17, 22, 26 updated
to use the canonical zb-mom-ww-mxgateway-client / zb-mom-ww-mxgateway-cli
names so the layout description matches Gradle / IDE project names.

Verification: gradle build BUILD SUCCESSFUL; all Java unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:21:06 -04:00
Joseph Doherty 865c22a884 Resolve IntegrationTests-022..024
IntegrationTests-022 (Conventions): ResolveRepositoryRoot now throws
InvalidOperationException when the walk exhausts without finding a root
marker, with a message naming the start directory, the expected markers
(src/, .git, *.sln, *.slnx), and the MXGATEWAY_LIVE_MXACCESS_WORKER_EXE
escape hatch. Replaces the silent fallback to
Directory.GetCurrentDirectory() that previously masked misconfiguration.
New regression test
ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers
in IntegrationTestEnvironmentTests asserts the throw and the message
contents. TDD red→green confirmed.

IntegrationTests-023 (Testing coverage): DashboardLdapLiveTests's
AuthenticateAsync_AdminInGwAdminGroup_Succeeds now asserts that the
authenticated principal carries a ClaimTypes.Role claim with value
DashboardRoles.Admin in addition to the existing LdapGroupClaimType
assertion. A regression in MapGroupsToRoles (returning an empty list or
missing the RDN fallback) would now surface here. Gated by
MXGATEWAY_RUN_LIVE_LDAP_TESTS.

IntegrationTests-024 (Conventions): Option (b) — extracted within
IntegrationTests. New file TestSupport/NullDashboardEventBroadcaster.cs
(public type, private ctor, singleton Instance). The inline class at
the bottom of WorkerLiveMxAccessSmokeTests is gone; the file now imports
the shared type. Matches the unit-test project's Tests-007 / Tests-021 /
Tests-025 pattern while keeping the two test projects independently
buildable (no shared test-helpers project crossing module boundaries).

Verification: dotnet build src/ZB.MOM.WW.MxGateway.IntegrationTests
clean; 19/19 integration tests passing (live MxAccess + LDAP + Galaxy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:20:40 -04:00
Joseph Doherty d48099f0d0 Resolve Tests-025..026
Tests-025 (Conventions): Extracted the previously-duplicated
NullDashboardEventBroadcaster into TestSupport/NullDashboardEventBroadcaster.cs
(singleton Instance, private ctor). The two nested copies in
EventStreamServiceTests and GatewayEndToEndFakeWorkerSmokeTests were
removed; both files now use the shared type via
'using ZB.MOM.WW.MxGateway.Tests.TestSupport;'. The Server-041 regression
test's ThrowingDashboardEventBroadcaster is intentionally left nested —
single-file usage doesn't warrant promotion to TestSupport. The third
copy in IntegrationTests/WorkerLiveMxAccessSmokeTests was handled by
IntegrationTests-024 in its own commit.

Tests-026 (Testing coverage): Added a new RecordingDashboardEventBroadcaster
test double in TestSupport — a thread-safe (ConcurrentQueue<DashboardEventCapture>)
recorder. New fixture
StreamEventsAsync_PublishesEachEventToDashboardBroadcaster in
EventStreamServiceTests pushes two events through the fake session and
asserts the broadcaster received both with the correct sessionId and
WorkerSequence. TDD red→green confirmed: the deliberately-wrong
"Expected 3, Actual 2" red phase proved the recording fake was actually
invoked by the production code path.

Verification: 486/486 server tests passing (485 previous + 1 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:20:40 -04:00
Joseph Doherty bd1d1f1c0e Resolve Contracts-016..017
Contracts-016 (Conventions): QueryActiveAlarmsRequest.session_id header
replaced with the unambiguous "Clients may leave session_id empty; the
gateway currently ignores it and serves the session-less central-monitor
cache. A future version may use it to scope the snapshot to one
session." Removes the ambiguity that the prior "reserved for future
use" wording introduced.

Contracts-017 (Documentation): The rpc QueryActiveAlarms comment now
includes the alarm_filter_prefix description: "QueryActiveAlarmsRequest.alarm_filter_prefix
optionally narrows the snapshot to alarms whose alarm_full_reference
starts with the given prefix; an empty prefix returns the full set."

Both are proto-comment-only changes — no wire-format impact, no field
renumbering, and the regenerated MxaccessGateway.cs / MxaccessGatewayGrpc.cs
carry only the doc-comment delta. Added the additive-only regression
guard QueryActiveAlarmsRequest_PinsFieldNumbersAndRoundTripsPrefixFilter
to ProtobufContractRoundTripTests — pins
session_id=1 / client_correlation_id=2 / alarm_filter_prefix=3 by
descriptor lookup and round-trips the message with and without the
filter populated.

Verification: dotnet build src/ZB.MOM.WW.MxGateway.slnx clean;
ProtobufContractRoundTripTests 40/40 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:20:13 -04:00
Joseph Doherty 327e9c5f94 Resolve Server-031..032 (re-triaged) + Server-038..043
Server-031: re-triaged. The recommended gateway-side
"skip-while-command-in-flight" guard is already in place at
WorkerClient.HeartbeatLoopAsync via WorkerClientOptions.HeartbeatStuckCeiling
(default 75s = 5× HeartbeatGrace). Two regression tests pin the
behaviour. Recommendation #1 (decouple worker-side _writeLock) is a
Worker-module concern (Worker-017 / Worker-023) and out of scope here.

Server-032: re-triaged. Recommendation #2 (rich diagnostic) is already
in EnqueueWorkerEventAsync, with #3 (overflow grace) absorbed by the
TryWrite → WriteAsync-with-timeout fall-through. Test
EnqueueWorkerEvent_WhenChannelFullPastTimeout_FaultsWithRichDiagnostic
pins the diagnostic string. Recommendation #1 (prose contract in
gateway.md / docs) is deferred — outside this pass's edit scope.

Server-038 (Security): EventsHub.SubscribeSession's missing per-session
ACL is documented with a TODO(per-session-acl) and a <remarks> block
explaining the v1 acceptance (any dashboard role can subscribe to any
session — non-secret metadata, redacted value logging). The per-session
ACL design lands in a follow-up once a session-scoped role exists.

Server-039 (Error handling): HubTokenService.Validate now rejects a
deserialized payload where both Name and NameIdentifier are null/empty.
New test file HubTokenServiceTests.cs covers the regression and five
sanity cases. TDD confirmed.

Server-040 (Conventions): MapGroupsToRoles gains a precedence comment
explaining "full literal match first, leading-RDN fallback;
OrdinalIgnoreCase via DashboardOptions.GroupToRole". Documentation-only.

Server-041 (Design adherence): EventStreamService.ProduceEventsAsync
wraps the broadcaster.Publish call in try/catch (Exception). The
producer loop and gRPC stream are no longer at the mercy of the
broadcaster's never-throw discipline. New regression test
StreamEventsAsync_WhenDashboardBroadcasterThrows_StillYieldsEventsAndDoesNotFaultSession.

Server-042 (Performance): DashboardSnapshotPublisher.ExecuteAsync now
mirrors AlarmsHubPublisher's reconnect loop — wraps the await foreach
in a while-not-cancelled, catches general exceptions, and Task.Delays
5s before retrying. An internal ctor accepts a shorter delay for the
test. New test file DashboardSnapshotPublisherTests.cs covers the
throw-then-yield reconnect path and the normal-completion case.

Server-043 (Documentation): HubTokenService class XML doc gains a
<remarks> describing the singleton lifetime, the two consumer scopes
(DashboardHubConnectionFactory scoped, HubTokenAuthenticationHandler
transient), and the thread-safety contract.

Verification: dotnet build src/ZB.MOM.WW.MxGateway.slnx clean
(0 warnings / 0 errors); src/ZB.MOM.WW.MxGateway.Tests 486/486 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:18:52 -04:00
Joseph Doherty d2d2e5f68f code-review 2026-05-24: re-review at d692232 across all 11 modules
Restores the `code-reviews/` tree (was unwritten on this working copy)
and re-reviews every module per `REVIEW-PROCESS.md` against HEAD
`d692232`. The diff in scope is the five commits since the last sweep:
`dc9c0c9` (ZB.MOM.WW gateway-side rename + slnx migrate),
`397d3c5` (client SDK rename + the missing alarm-RPC proto types and
the .NET DiscoverHierarchyOptions POCO), `27ed651` (role-based LDAP
auth + HubToken bearer, drop PathBase), `6594359` (sidebar layout +
three SignalR push hubs), and `d692232` (EventsHub publisher + doc
refresh).

Module status

| Module | Open | Total | Delta this pass |
|---|---|---|---|
| Server           | 8 | 43 | +6 |
| Contracts        | 2 | 17 | +2 |
| Tests            | 2 | 26 | +2 |
| IntegrationTests | 3 | 24 | +3 |
| Client.Java      | 5 | 31 | +5 |
| Client.Rust      | 1 | 21 | +1 |
| Worker           | 0 | 25 |  0 (rename-only diff, clean) |
| Worker.Tests     | 0 | 30 |  0 (rename-only diff, clean) |
| Client.Dotnet    | 0 | 17 |  0 (rename + alarm-fix diff, clean) |
| Client.Python    | 0 | 21 |  0 (rename + alarm-fix diff, clean) |
| Client.Go        | 0 | 21 |  0 (rename + alarm-fix diff, clean) |

Total new findings: 19. Severity breakdown: 1 Medium-security
(Server-038), 4 Medium-documentation/coverage, 14 Low.

New findings

  * Server-038 (Medium / Security) — EventsHub.SubscribeSession accepts
    any session id from any Viewer; no per-session ACL guards the
    EventsHub group fan-out.
  * Server-039 (Low / Error handling) — HubTokenService.Validate
    accepts a payload with null Name/NameIdentifier.
  * Server-040 (Low / Conventions) — MapGroupsToRoles undocumented
    full-vs-RDN lookup precedence.
  * Server-041 (Low / Design adherence) — EventStreamService calls
    IDashboardEventBroadcaster.Publish without a try/catch — fragile
    seam relying on the never-throw contract.
  * Server-042 (Low / Performance) — DashboardSnapshotPublisher tight
    retry loop with no backoff (vs AlarmsHubPublisher 5s delay).
  * Server-043 (Low / Documentation) — HubTokenService singleton
    sharing across login + hub-token validation undocumented.

  * Contracts-016 (Low / Conventions) — QueryActiveAlarmsRequest.session_id
    reserved-for-future-use ambiguity.
  * Contracts-017 (Low / Documentation) — rpc QueryActiveAlarms doc
    omits the alarm_filter_prefix filter description.

  * Tests-025 (Low / Conventions) — duplicate NullDashboardEventBroadcaster
    fakes in EventStreamServiceTests and GatewayEndToEndFakeWorkerSmokeTests.
  * Tests-026 (Medium / Testing coverage) — no test proves
    EventStreamService actually calls IDashboardEventBroadcaster.Publish.

  * IntegrationTests-022 (Low / Conventions) — ResolveRepositoryRoot
    silent fallback to Directory.GetCurrentDirectory().
  * IntegrationTests-023 (Low / Testing coverage) — DashboardLdapLiveTests
    success-path asserts ldap_group but not the Role claim.
  * IntegrationTests-024 (Low / Conventions) — inline
    NullDashboardEventBroadcaster fake duplicates Tests-side copies.

  * Client.Java-027 (Medium / Documentation) — README + JavaClientDesign
    Gradle task names still use the old short project names.
  * Client.Java-028 (Medium / Design adherence) — JavaClientDesign
    build-layout shows the old `com/dohertylan/mxgateway/` package paths.
  * Client.Java-029 (Low / Documentation) — README installDist path
    cites the wrong directory.
  * Client.Java-030 (Low / Testing coverage) — no Java test exercises
    the regenerated QueryActiveAlarmsRequest RPC.
  * Client.Java-031 (Low / Conventions) — README prose uses old short
    project names instead of canonical prefixed ones.

  * Client.Rust-021 (Low / Design adherence) — RustClientDesign.md
    "Crate layout" shows an aspirational nested `crates/zb-mom-ww-mxgateway-client/`
    that does not exist; actual layout is the flat top-level crate.

Two pre-existing pending findings (Server-031 lock-contention,
Server-032 bounded event channel) remain unchanged — neither was
touched by this wave of commits.

Process notes

- The `code-reviews/` tree was not in this working copy's git
  history (the local extract pre-dates the divergent branch that
  carried the reviews). Restored from `dd7ca16` via
  `git checkout dd7ca16 -- code-reviews/` before the re-review.
- Some "Resolved" entries in the restored findings.md reference
  fixes that landed on the divergent branch (the same one that
  carried the reviews) and are not present on the current main
  lineage. The re-review treats those statuses as historical;
  the new pass only files findings against HEAD's actual state.
- `python code-reviews/regen-readme.py --check` is green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:34:30 -04:00
Joseph Doherty d692232191 dashboard: clear deferred items — EventsHub publisher + doc refresh
EventsHub publisher (closes the v2.1 follow-up flagged in the previous commit)

EventStreamService now mirrors every MxEvent it forwards to a gRPC client
into the `EventsHub` group for the session. The fan-out goes through a new
singleton `IDashboardEventBroadcaster`:

  * IDashboardEventBroadcaster — abstraction so EventStreamService doesn't
    take a direct dependency on SignalR.
  * DashboardEventBroadcaster — singleton implementation that hands the
    SendAsync to IHubContext<EventsHub> as fire-and-forget. Errors are
    logged at debug and dropped so the source gRPC stream is never
    blocked.

EventStreamService now takes IDashboardEventBroadcaster as a ctor parameter
and calls Publish(sessionId, publicEvent) once per event after sequence
filtering, before the bounded queue write. Test fixtures and the live
integration harness pass NullDashboardEventBroadcaster.Instance so the
broadcaster is a no-op in unit tests.

SessionDetailsPage adds a "Recent events" panel:
  * implements IAsyncDisposable
  * opens a second HubConnection via DashboardHubConnectionFactory targeting
    /hubs/events
  * calls SubscribeSession(SessionId) on Start
  * renders the most recent 50 events in a small table (worker seq, family,
    server/item handle, alarm reference when the event is OnAlarmTransition)
  * shows a live/offline conn-pill driven by HubConnection.Closed /
    Reconnected events

The dashboard mirror is intentionally passive — events appear only while a
gRPC client is also consuming that session's events. Documented as such in
the empty-state copy and in GatewayDashboardDesign.md.

Documentation refresh

Every doc that referenced the retired options (PathBase, RequireAdminScope,
RequiredGroup) and the old API-key-cookie auth flow is updated to describe
the new model:

  * CLAUDE.md — Authentication section now explains LDAP bind +
    GroupToRole + HubToken bearer flow.
  * gateway.md — Dashboard section: root-mounted routes, snapshot/alarms/
    events SignalR hubs, LDAP cookie + bearer scheme.
  * docs/GatewayConfiguration.md — drop PathBase / RequireAdminScope rows,
    add GroupToRole row, append "Authorization policies" and "SignalR hubs"
    subsections describing the three policies and the /hubs/* endpoints.
  * docs/GatewayDashboardDesign.md — hosting model (root mount, new
    endpoint layout), Realtime Updates rewritten as a hub table
    (DashboardSnapshotHub / AlarmsHub / EventsHub with producers, payloads,
    and routing), Authentication And Authorization rewritten around LDAP +
    role mapping + the hub bearer flow, Configuration block updated.
  * docs/GatewayProcessDesign.md — security-section dashboard paragraph
    and the example config block both refreshed to LDAP/role auth.
  * docs/ImplementationPlanGateway.md — dashboard-auth deliverable list
    updated (LDAP bind + GroupToRole + /hubs/token bearer mint replace the
    API-key login flow).
  * docs/GatewayTesting.md — DashboardLdapLiveTests blurb describes the
    GroupToRole fixture (`{ GwAdmin: Admin }`) instead of the retired
    RequiredGroup default; success-path assertion explains the role-claim
    check.

Verification: 475 server tests, 275 worker tests (+ 9 dev-rig skips), 18
integration tests (live MxAccess + LDAP + Galaxy) all pass — including the
live worker smoke test fixture that now constructs EventStreamService with
the new broadcaster parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:07:30 -04:00
Joseph Doherty 65943597d4 dashboard: side-rail layout + SignalR push hubs (snapshot, alarms, events)
Layout
------
DashboardLayout.razor replaces the inline header nav with a left side rail
modelled on the OtOpcUa admin (Dashboard B). The top bar keeps only the
brand, breadcrumb, and signed-in status pill; navigation moves into a
fixed-width 218px rail with grouped section eyebrows (Overview,
Runtime, Galaxy, Admin) and a Session footer carrying the user name,
role claims, and a Sign-out button. dashboard.css gains the
`.app-shell` flex container, `.side-rail` column, `.rail-eyebrow`,
`.rail-link[.active]`, `.rail-foot`, `.rail-user`, `.rail-roles`, and
`.rail-btn` rules (all driven by the existing theme.css tokens, no new
hard-coded colours).

SignalR (push)
--------------
Adds three hubs under `Dashboard/Hubs/`, all gated by the
`HubClientsPolicy` registered in the previous commit:

  * DashboardSnapshotHub (/hubs/snapshot)
    Broadcasts the full DashboardSnapshot on every change. Sends the
    current snapshot to a new caller in OnConnectedAsync so the first
    paint is immediate.

  * AlarmsHub (/hubs/alarms)
    Connected clients auto-join the `__alarms__` group. Receives
    AlarmFeedMessage values (active_alarm / snapshot_complete /
    transition) re-broadcast from the gateway's central alarm monitor.

  * EventsHub (/hubs/events)
    Per-session push surface. Clients call SubscribeSession(sessionId)
    to join `session:{id}`. The publisher side is intentionally a
    follow-up — the snapshot hub already carries recent-events
    rollups; a dedicated MxEvent broadcaster on EventStreamService
    will plug into this hub's group convention.

Two BackgroundService publishers wire server-side data sources to the
hubs:

  * DashboardSnapshotPublisher subscribes to
    `IDashboardSnapshotService.WatchSnapshotsAsync` and forwards every
    snapshot to all connected hub clients.
  * AlarmsHubPublisher subscribes to `IGatewayAlarmService.StreamAsync`
    (no filter) and forwards every AlarmFeedMessage to the
    `__alarms__` group, reconnecting with a 5-second backoff if the
    stream faults.

Connection + auth plumbing
--------------------------
  * `GET /hubs/token` issues a fresh data-protected bearer token
    bound to the calling user's identity and roles. Gated by the
    cookie-only ViewerPolicy so a Blazor circuit (cookie-authenticated)
    can mint a token, but a hub bearer cannot self-bootstrap a new
    one.
  * DashboardHubConnectionFactory (scoped) is the client-side helper
    Razor pages inject. It builds a HubConnection with an
    AccessTokenProvider that calls HubTokenService.Issue on every
    (re)connect — keeps the connection alive across cookie refresh
    boundaries.

Pull → push refactor
--------------------
DashboardPageBase no longer drives its own `WatchSnapshotsAsync`
async-foreach loop. It now:
  1. seeds Snapshot synchronously from `IDashboardSnapshotService.GetSnapshot()`
     so the first render is non-empty;
  2. opens a `DashboardSnapshotHub` connection via the connection
     factory;
  3. updates Snapshot + triggers StateHasChanged on each
     `SnapshotUpdated` push.

The hub connection is best-effort: if SignalR can't start, the
synchronous snapshot seed keeps the UI populated. SignalR's
WithAutomaticReconnect handles the recovery path.

Package
-------
Adds `Microsoft.AspNetCore.SignalR.Client` 10.0.0 to the server csproj
so the in-process Blazor pages can open hub connections back to their
own hosting process.

Verification: 475 server tests (+ 2 new
`DashboardHubsRegistrationTests` that pin the hub negotiate endpoints
and the singleton/scoped DI shape), 275 worker tests (+ 9 dev-rig
skips), 18 integration tests (live MxAccess + LDAP + Galaxy) all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:48:27 -04:00
Joseph Doherty 27ed65114e dashboard: role-based LDAP auth + hub bearer scheme, drop PathBase
Restructure dashboard auth around LDAP-driven Admin/Viewer roles, add a
bearer scheme so SignalR hubs (next commit) can authenticate without
forwarding the HttpOnly browser cookie, and mount the dashboard at the
host root instead of a configurable `/dashboard` prefix.

Configuration changes (breaking):
- `MxGateway:Dashboard:PathBase` removed — the dashboard now serves at `/`.
- `MxGateway:Dashboard:RequireAdminScope` removed — role checks replace
  the single admin-scope claim.
- `MxGateway:Ldap:RequiredGroup` removed — replaced by `MxGateway:Dashboard:GroupToRole`,
  a map from LDAP group name to dashboard role. Legal role values:
  `Admin` and `Viewer`. Users whose LDAP groups don't intersect this
  map are rejected at login (the existing fail-closed contract).
- appsettings.json ships a default mapping `{ GwAdmin: Admin, GwReader: Viewer }`.

Auth model:
- DashboardRoles: new static class with `Admin` and `Viewer` constants.
- DashboardAuthenticator.AuthenticateAsync: after LDAP bind, maps the
  user's groups through `DashboardOptions.GroupToRole` and emits one
  `ClaimTypes.Role` claim per resolved role. Empty result → login fails.
- DashboardAuthorizationRequirement now carries `RequiredRoles`; static
  presets `AnyDashboardRole` (Viewer ∨ Admin) and `AdminOnly`.
- DashboardAuthorizationHandler checks `IsInRole` against the
  requirement's role list instead of the old scope claim. The
  `AuthenticationMode.Disabled` and `AllowAnonymousLocalhost` bypasses
  are preserved.
- DashboardApiKeyAuthorization.CanManage now requires the `Admin` role
  (was: required LDAP group membership). The constructor's IOptions
  parameter is gone.

Policies / schemes:
- DashboardAuthenticationDefaults gains `ViewerPolicy`, `AdminPolicy`,
  `HubClientsPolicy`, and `HubAuthenticationScheme`. The legacy
  `AuthorizationPolicy` and `ScopeClaimType` constants are removed.
- DashboardServiceCollectionExtensions registers all three policies,
  adds the cookie scheme and the HubToken bearer scheme side by side,
  calls `AddSignalR()`, and hard-codes the cookie's login/logout/denied
  paths to root-relative `/login` etc.

Hub bearer infrastructure (no hubs wired yet — next commit):
- HubTokenService: mints time-limited data-protected JSON tokens
  carrying the user's name, NameIdentifier, and roles. 30-minute
  lifetime, purpose `ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`.
- HubTokenAuthenticationHandler: validates the token from
  `Authorization: Bearer …` or `?access_token=…` (WebSocket upgrade
  query string) and rebuilds the principal.

Endpoint mapping:
- DashboardEndpointRouteBuilderExtensions drops the `MapGroup(pathBase)`
  wrapper. Login/logout/denied and Razor component routes are now
  mounted at `/`. The login form posts to `/login`. Razor components
  require the new `ViewerPolicy`.
- All page `@page "/dashboard/X"` dual-route directives are removed —
  pages live at their canonical roots (`@page "/"`, `@page "/sessions"`, …).
- App.razor and DashboardLayout.razor drop their PathBase computations.

EffectiveLdapConfiguration drops `RequiredGroup`; EffectiveDashboardConfiguration
drops `PathBase`/`RequireAdminScope` and gains `GroupToRole`. SettingsPage
renders the role mapping in place of the retired fields.

Tests updated:
- DashboardAuthenticatorTests: covers the new GroupToRole mapping
  (short name + DN + multi-role).
- DashboardAuthorizationHandlerTests: split into Viewer-policy and
  Admin-policy cases.
- DashboardApiKeyAuthorizationTests, DashboardApiKeyManagementServiceTests:
  authorized principal now carries the `Admin` role claim.
- DashboardCookieOptionsTests: expects root-relative login/logout paths.
- GatewayApplicationTests: dashboard component routes registered at `/`,
  `/sessions`, … and gated by `ViewerPolicy`. Filter on
  `ComponentTypeMetadata` to ignore minimal-API endpoints sharing `/`.
- GatewayOptionsTests + Validator: drop PathBase / RequireAdminScope /
  RequiredGroup assertions; add a `GroupToRole` value-validation case.
- DashboardLdapLiveTests: provides the default `GwAdmin` → `Admin`
  mapping so the live LDAP bind resolves to a role.

Verification: 473 server tests, 275 worker tests (+9 dev-rig skips), 18
integration tests (live MxAccess + LDAP + Galaxy) all pass.

This commit is intentionally UI-neutral. The sidebar layout and the
SignalR hubs that consume the new HubToken scheme land in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:38:33 -04:00
Joseph Doherty 397d3c5c4f rename: apply ZB.MOM.WW prefix to all client SDKs + fix pre-existing alarm-RPC breaks
Rename across every client surface using each language's idiomatic convention:

  * .NET   clients/dotnet/MxGateway.Client[.Cli|.Tests]/
             -> clients/dotnet/ZB.MOM.WW.MxGateway.Client[.Cli|.Tests]/
             namespaces -> ZB.MOM.WW.MxGateway.Client[.Cli|.Tests]
             contracts ProjectReference repointed to ZB.MOM.WW.MxGateway.Contracts
             sln migrated to slnx (dotnet sln migrate)
  * Python src/mxgateway -> src/zb_mom_ww_mxgateway
             src/mxgateway_cli -> src/zb_mom_ww_mxgateway_cli
             distribution: mxaccess-gateway-client -> zb-mom-ww-mxaccess-gateway-client
  * Rust   crate: mxgateway-client -> zb-mom-ww-mxgateway-client
             build.rs proto path repointed
  * Java   subprojects: mxgateway-{client,cli} -> zb-mom-ww-mxgateway-{client,cli}
             packages com.dohertylan.mxgateway -> com.zb.mom.ww.mxgateway
             group   com.dohertylan.mxgateway -> com.zb.mom.ww.mxgateway
             rootProject mxaccessgw-java -> zb-mom-ww-mxaccessgw-java
  * Go     generate-proto.ps1 proto path repointed; module path and
             package mxgateway kept (Go convention).
  * proto-inputs.json: generatedOutputs.python updated to new package path.
  * scripts/run-client-e2e-tests.ps1: Java CLI install path + gradle task
             updated to zb-mom-ww-mxgateway-cli.

CLI binary names (mxgw, mxgw-py, mxgw-go, mxgateway-cli) and wire-level
identifiers (MXGATEWAY_* env vars, the mxgw_<id>_<secret> API key
prefix, protobuf package names like mxaccess_gateway.v1, all MXAccess
references) intentionally NOT renamed.

Fix pre-existing alarms-over-gateway breaks unblocked by the rename:

  * mxaccess_gateway.proto: add missing public message QueryActiveAlarmsRequest
    {session_id, client_correlation_id, alarm_filter_prefix} and missing
    rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns
    (stream ActiveAlarmSnapshot). All four typed clients referenced
    these but they were absent from the proto.
  * MxAccessGatewayService.QueryActiveAlarms: implement the new RPC on
    the server, streaming from IGatewayAlarmService.CurrentAlarms with
    optional alarm_filter_prefix filter.
  * clients/dotnet/.../DiscoverHierarchyOptions.cs: add the hand-written
    .NET POCO that wraps DiscoverHierarchyRequest (referenced by
    GalaxyRepositoryClient.DiscoverHierarchyAsync but never authored).
  * Drop retired session_id field references from
    AcknowledgeAlarmRequest/AcknowledgeAlarmReply test fixtures across
    .NET, Rust, Go, and Python clients.
  * Rust integration test: add the missing stream_alarms impl on the
    fake MxAccessGateway server (the trait gained the method, fake
    didn't).
  * Rust CLI test: bump expected gatewayProtocolVersion 2 -> 3.

Regenerated artifacts updated in this commit:
  * src/ZB.MOM.WW.MxGateway.Contracts/Generated/{MxaccessGateway,MxaccessGatewayGrpc}.cs
  * clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2{,_grpc}.py
  * clients/go/internal/generated/*.pb.go
(C# regenerated by Grpc.Tools on contracts build; Python and Go via
their generate-proto.ps1 scripts; Rust regenerates from .proto via
tonic-build at compile time so no checked-in artefact.)

Verification: 472 server tests, 275 worker tests (9 dev-rig skipped),
18 integration tests (live MxAccess + LDAP + Galaxy), 57 .NET client
tests, 32 Rust workspace tests, 39 Python tests, all Go packages, and
gradle build for Java all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:09:34 -04:00
Joseph Doherty dc9c0c950c rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.

External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
  MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths

Also fixes two tests that were not rename-related but became visible
while validating the rename:

- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
  gateway service correctly maps to RpcException(Cancelled) per gRPC
  convention was being misclassified as a stream fault. Added a sibling
  catch on RpcException with StatusCode.Cancelled.

- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
  and made it accept either a .git marker OR a .sln/.slnx next to src/
  so the worker-exe walker works in non-git working copies.

clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.

Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
  Tests: 472/472 pass
  Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
  IntegrationTests: 18/18 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:22:23 -04:00
dohertj2 867bf18116 alarms-over-gateway: full pipeline (#118)
Seven slices on this branch implement the full alarms-over-gateway path:

1. f711a55  A.2: WnWrapAlarmConsumer replaces aaAlarmManagedClient (wnwrapConsumer.dll, XML payload bypasses FILETIME crash)
2. 82eb0ad  A.3 in-process: AlarmDispatcher wires consumer events onto worker MxAccessEventQueue
3. 01f5e6a  A.3 worker IPC: SubscribeAlarms / UnsubscribeAlarms / AcknowledgeAlarm / QueryActiveAlarms commands + executor switch arms
4. 9b21ca3  A.3 gateway: WorkerAlarmRpcDispatcher routes RPCs through the IPC; replaces NotWiredAlarmRpcDispatcher in DI
5. 47b1fd4  A.3 auto-subscribe: SessionManager issues SubscribeAlarms on session open (gated by Alarms.Enabled config)
6. 4e02927  A.3 alarm-ack-by-name: public AcknowledgeAlarm now accepts Provider!Group.Tag references via AlarmAckByName
7. a4ed605  A.3 live smoke: end-to-end pipeline verified on dev rig; surfaced + fixed three production-relevant AVEVA quirks (SetXmlAlarmQuery required for reads, breaks acks; v2 8-arg AlarmAckByName is a stub; AlarmAckByGUID is a stub)

Known follow-ups not in scope:
 - WnWrapAlarmConsumer.PollOnce needs to be driven from the worker StaRuntime (production hosting); currently the timer-based path deadlocks on cross-apartment marshaling without an STA pump.
 - Pre-existing structure-test failure (test project ArchestrA.MxAccess ref) untouched.

Test counts at merge time:
  Worker: 195 pass / 4 skipped (live probes incl. AlarmsLiveSmokeTests) / 1 pre-existing fail
  Server: 308 pass / 0 fail
2026-05-01 12:31:27 -04:00
689 changed files with 13662 additions and 15356 deletions
+3 -3
View File
@@ -32,7 +32,7 @@ dotnet test src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj -p:Platform
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
# API-key admin CLI (same exe, "apikey" subcommand) # API-key admin CLI (same exe, "apikey" subcommand)
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session:open,session:close,invoke:read,invoke:write,invoke:secure,events:read,metadata:read,admin dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session,invoke,event,metadata,admin
``` ```
Single test by name (xUnit `--filter`): Single test by name (xUnit `--filter`):
@@ -114,9 +114,9 @@ External analysis sources referenced by design docs:
## Authentication ## 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/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`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`.
Dashboard auth uses the same verifier but exchanges the API key for an HTTP-only secure cookie at `/dashboard/login`. `Dashboard:AllowAnonymousLocalhost` bypasses cookie auth on loopback when explicitly enabled. 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 `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 30-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` bypasses auth on loopback when enabled.
## Process / Platform Notes ## Process / Platform Notes
-140
View File
@@ -1,140 +0,0 @@
# Code Review Process
This document describes how to perform a comprehensive, per-module code review of
the `mxaccessgw` codebase and how to track findings to resolution.
A **module** is one buildable project under `src/` (e.g. `src/MxGateway.Worker`)
or one language client under `clients/` (e.g. `clients/rust`). Each module has
its own folder under `code-reviews/` containing a single `findings.md`.
## 1. Before you start
1. Pick the module to review. Its folder is `code-reviews/<Module>/`:
- For a `src/` project, `<Module>` is the project name with the `MxGateway.`
prefix stripped — `src/MxGateway.Server` is reviewed in `code-reviews/Server/`.
- For a language client, `<Module>` is `Client.<Lang>``clients/rust` is
reviewed in `code-reviews/Client.Rust/`.
2. Identify the design context for the module:
- `gateway.md` — top-level architecture, command/event surface, IPC envelope,
STA thread model, fault handling.
- The relevant component design docs under `docs/` (e.g.
`docs/MxAccessWorkerInstanceDesign.md`, `docs/GatewayProcessDesign.md`,
`docs/Sessions.md`, `docs/Authentication.md`, `docs/GalaxyRepository.md`).
- `docs/DesignDecisions.md` for the v1 design choices.
- The **Repository-Specific Conventions** and **Process / Platform Notes** in
`CLAUDE.md`.
3. Record the exact commit being reviewed: `git rev-parse --short HEAD`. Every
review is a snapshot — a finding only means something relative to a known
commit.
4. Open `code-reviews/<Module>/findings.md` and fill in the header table
(reviewer, date, commit SHA, status).
## 2. Review checklist
Work through **every** category below for the module. A comprehensive review
means the checklist is completed even where it produces no findings — record
"No issues found" for a category rather than leaving it ambiguous.
1. **Correctness & logic bugs** — off-by-one, null handling, incorrect
conditionals, misuse of APIs, broken edge cases.
2. **mxaccessgw conventions** — the rules in `CLAUDE.md` and the style guides
under `docs/style-guides/`: the gateway never instantiates MXAccess COM
directly; all MXAccess COM calls run on the worker's dedicated STA thread and
the STA loop pumps Windows messages; IPC uses one bidirectional named pipe per
worker carrying length-prefixed `WorkerEnvelope` protobuf frames; MXAccess
parity is the contract (don't "fix" surprising MXAccess behaviour, never
synthesize events); one worker and one event subscriber per session; the
gateway terminates orphan workers on startup and does not reattach; C# style
(file-scoped namespaces, `sealed` by default, `Async` suffix, MXAccess-aligned
names); no Blazor UI component libraries; no logging of secrets or full tag
values; generated code is never hand-edited.
3. **Concurrency & thread safety** — shared mutable state, STA affinity, race
conditions, correct use of `async`/`await`, locking, disposal races.
4. **Error handling & resilience** — exception paths, worker crash / reconnect
handling, fail-fast event backpressure, transient vs permanent error
classification, graceful degradation, correct gRPC status codes.
5. **Security** — authentication/authorization checks, API-key scope enforcement,
input validation, SQL injection in the Galaxy Repository RPCs, secret
handling, the dashboard anonymous-localhost bypass, logging of sensitive data.
6. **Performance & resource management**`IDisposable` disposal, pipe / stream
/ COM lifetimes, buffering and back-pressure, unnecessary allocations on hot
paths, N+1 queries.
7. **Design-document adherence** — does the code match `gateway.md`, the relevant
`docs/` component designs, `docs/DesignDecisions.md`, and `CLAUDE.md`? Flag
both code that drifts from the design and design docs that are now stale.
8. **Code organization & conventions** — namespace hierarchy, project layout, the
Options pattern, separation of concerns, additive-only contract evolution.
9. **Testing coverage** — are the module's behaviours covered by tests
(`src/MxGateway.Tests`, `src/MxGateway.Worker.Tests`,
`src/MxGateway.IntegrationTests`)? Note untested critical paths and missing
edge-case tests.
10. **Documentation & comments** — XML doc accuracy, misleading or stale comments,
undocumented non-obvious behaviour.
## 3. Recording findings
Add one entry per finding to the `## Findings` section of the module's
`findings.md`, using the entry format in
[`_template/findings.md`](code-reviews/_template/findings.md).
- **Finding ID** — `<Module>-NNN`, numbered sequentially within the module and
never reused (e.g. `Worker-001`). IDs are permanent even after resolution.
- **Severity:**
- **Critical** — data loss, security breach, crash/deadlock, or outage.
- **High** — incorrect behaviour with significant impact; no safe workaround.
- **Medium** — incorrect or risky behaviour with limited impact or a workaround.
- **Low** — minor issues, style, maintainability, documentation.
- **Category** — one of the 10 checklist categories above.
- **Location** — `file:line` (clickable), or a list of locations.
- **Description** — what is wrong and why it matters.
- **Recommendation** — concrete suggested fix.
After recording findings, update the module header table (status, open-finding
count) and regenerate the base README (step 5).
## 4. Marking an item resolved
Findings are **never deleted** — they are an audit trail. To close one, change
its **Status** and complete the **Resolution** field:
- `Open` — newly recorded, not yet addressed.
- `In Progress` — a fix is actively being worked on.
- `Resolved` — fixed. The Resolution field must state the fixing commit SHA, the
date, and a one-line description of the fix.
- `Won't Fix` — intentionally not fixed. The Resolution field must justify why.
- `Deferred` — valid but postponed. The Resolution field must say what it is
waiting on (e.g. a tracked issue or a later milestone).
`Resolved`, `Won't Fix`, and `Deferred` findings are all considered **closed**.
`Open` and `In Progress` are **pending** and appear in the base README's Pending
Findings table.
## 5. Updating the base README
`code-reviews/README.md` holds the single cross-module view (the Module Status
table and the Pending / Closed Findings tables). It is **generated** from the
per-module `findings.md` files — do not edit it by hand.
After any review or status change, regenerate it:
```
python code-reviews/regen-readme.py
```
`regen-readme.py --check` exits non-zero if `README.md` is stale, if a module
header's `Open findings` count disagrees with its finding statuses, or if a
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`.
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.
## 6. Re-reviewing a module
Re-reviews append to the same `findings.md`. Update the header to the new commit
and date, continue the finding numbering from the last used ID, and leave prior
findings (including closed ones) in place as history.
-17
View File
@@ -1,17 +0,0 @@
<Project>
<!--
Mirrors src/Directory.Build.props for the .NET client projects under
clients/dotnet/ so they share the same enforcement floor (warnings-as-
errors, latest analyzers, code-style enforcement, deterministic builds)
even though they live outside src/.
-->
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<Deterministic>true</Deterministic>
</PropertyGroup>
</Project>
+11 -11
View File
@@ -16,9 +16,9 @@ Recommended layout:
```text ```text
clients/dotnet/ clients/dotnet/
MxGateway.Client.sln ZB.MOM.WW.MxGateway.Client.slnx
MxGateway.Client/ ZB.MOM.WW.MxGateway.Client/
MxGateway.Client.csproj ZB.MOM.WW.MxGateway.Client.csproj
GatewayClient.cs GatewayClient.cs
MxGatewaySession.cs MxGatewaySession.cs
MxGatewayClientOptions.cs MxGatewayClientOptions.cs
@@ -26,14 +26,14 @@ clients/dotnet/
Conversion/ Conversion/
Errors/ Errors/
Generated/ Generated/
MxGateway.Client.Cli/ ZB.MOM.WW.MxGateway.Client.Cli/
MxGateway.Client.Cli.csproj ZB.MOM.WW.MxGateway.Client.Cli.csproj
Program.cs Program.cs
Commands/ Commands/
MxGateway.Client.Tests/ ZB.MOM.WW.MxGateway.Client.Tests/
MxGateway.Client.Tests.csproj ZB.MOM.WW.MxGateway.Client.Tests.csproj
MxGateway.Client.IntegrationTests/ ZB.MOM.WW.MxGateway.Client.IntegrationTests/
MxGateway.Client.IntegrationTests.csproj ZB.MOM.WW.MxGateway.Client.IntegrationTests.csproj
``` ```
Target framework: Target framework:
@@ -43,7 +43,7 @@ Target framework:
``` ```
The scaffold uses a project reference to The scaffold uses a project reference to
`src/MxGateway.Contracts/MxGateway.Contracts.csproj` for generated protobuf and `src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` for generated protobuf and
gRPC types. `clients/dotnet/generated` remains reserved for client-local gRPC types. `clients/dotnet/generated` remains reserved for client-local
generator output if the .NET client later needs to decouple from the contracts generator output if the .NET client later needs to decouple from the contracts
project. project.
@@ -166,7 +166,7 @@ reply.EnsureMxAccessSuccess();
## Test CLI ## Test CLI
Project: `MxGateway.Client.Cli`. Project: `ZB.MOM.WW.MxGateway.Client.Cli`.
Command examples: Command examples:
@@ -1,76 +0,0 @@
using Grpc.Core;
namespace MxGateway.Client.Tests;
/// <summary>Tests for the shared gRPC-to-native exception mapping used by the transports.</summary>
public sealed class RpcExceptionMapperTests
{
/// <summary>Verifies that an unauthenticated status maps to the authentication exception.</summary>
[Fact]
public void Map_UnauthenticatedStatus_ProducesAuthenticationException()
{
RpcException rpc = new(new Status(StatusCode.Unauthenticated, "no key"));
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
MxGatewayAuthenticationException authentication =
Assert.IsType<MxGatewayAuthenticationException>(mapped);
Assert.Equal(StatusCode.Unauthenticated, authentication.StatusCode);
}
/// <summary>Verifies that a permission-denied status maps to the authorization exception.</summary>
[Fact]
public void Map_PermissionDeniedStatus_ProducesAuthorizationException()
{
RpcException rpc = new(new Status(StatusCode.PermissionDenied, "missing scope"));
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
MxGatewayAuthorizationException authorization =
Assert.IsType<MxGatewayAuthorizationException>(mapped);
Assert.Equal(StatusCode.PermissionDenied, authorization.StatusCode);
}
/// <summary>Verifies that a cancelled status maps to OperationCanceledException.</summary>
[Fact]
public void Map_CancelledStatus_ProducesOperationCanceledException()
{
RpcException rpc = new(new Status(StatusCode.Cancelled, "cancelled"));
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
Assert.IsType<OperationCanceledException>(mapped);
}
/// <summary>
/// Verifies that non-auth statuses surface the originating gRPC status code on the
/// mapped exception so callers can distinguish transient from permanent failures
/// without reflecting into InnerException.
/// </summary>
[Theory]
[InlineData(StatusCode.NotFound)]
[InlineData(StatusCode.InvalidArgument)]
[InlineData(StatusCode.ResourceExhausted)]
[InlineData(StatusCode.FailedPrecondition)]
[InlineData(StatusCode.Unavailable)]
[InlineData(StatusCode.Internal)]
public void Map_NonAuthStatus_CarriesStatusCodeOnMxGatewayException(StatusCode statusCode)
{
RpcException rpc = new(new Status(statusCode, "boom"));
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
MxGatewayException gatewayException = Assert.IsType<MxGatewayException>(mapped);
Assert.Equal(statusCode, gatewayException.StatusCode);
Assert.Same(rpc, gatewayException.InnerException);
}
/// <summary>Verifies that an MxGatewayException built without a gRPC status reports a null StatusCode.</summary>
[Fact]
public void StatusCode_IsNull_WhenNoGrpcStatusProvided()
{
MxGatewayException gatewayException = new("plain failure");
Assert.Null(gatewayException.StatusCode);
}
}
-76
View File
@@ -1,76 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client", "MxGateway.Client\MxGateway.Client.csproj", "{7CF9ED88-1F32-4040-BEB1-D0902E304C70}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Contracts", "..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj", "{9AB807A8-0469-40F7-A000-D240F36B6E5D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Cli", "MxGateway.Client.Cli\MxGateway.Client.Cli.csproj", "{EB061E77-2475-4322-9257-3F2456DD141C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Tests", "MxGateway.Client.Tests\MxGateway.Client.Tests.csproj", "{B77B5A8E-0C53-4419-9BCD-227C9753A074}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.ActiveCfg = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.Build.0 = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.ActiveCfg = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.Build.0 = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.Build.0 = Release|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.ActiveCfg = Release|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.Build.0 = Release|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.ActiveCfg = Release|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.Build.0 = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.ActiveCfg = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.Build.0 = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.ActiveCfg = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.Build.0 = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.Build.0 = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.ActiveCfg = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.Build.0 = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.ActiveCfg = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.Build.0 = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.ActiveCfg = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.Build.0 = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.ActiveCfg = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.Build.0 = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.Build.0 = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.ActiveCfg = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.Build.0 = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.ActiveCfg = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.Build.0 = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.ActiveCfg = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.Build.0 = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.ActiveCfg = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.Build.0 = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.Build.0 = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.ActiveCfg = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.Build.0 = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.ActiveCfg = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
@@ -1,67 +0,0 @@
namespace MxGateway.Client;
/// <summary>
/// Server-side filters and shape options for
/// <see cref="GalaxyRepositoryClient.DiscoverHierarchyAsync(DiscoverHierarchyOptions, System.Threading.CancellationToken)"/>.
/// Each property maps directly to the corresponding field on the
/// <c>DiscoverHierarchyRequest</c> proto so the gateway can narrow the
/// hierarchy walk before serializing it back to the client.
/// </summary>
public sealed record DiscoverHierarchyOptions
{
/// <summary>
/// Root Galaxy object id to start the walk from. When set, takes
/// precedence over <see cref="RootTagName"/> and <see cref="RootContainedPath"/>.
/// </summary>
public int? RootGobjectId { get; init; }
/// <summary>
/// Root tag (assigned) name to start the walk from. Used when
/// <see cref="RootGobjectId"/> is null.
/// </summary>
public string? RootTagName { get; init; }
/// <summary>
/// Root contained-name dotted path to start the walk from. Used when
/// neither <see cref="RootGobjectId"/> nor <see cref="RootTagName"/> are set.
/// </summary>
public string? RootContainedPath { get; init; }
/// <summary>
/// Maximum traversal depth below the root, inclusive. Leave null for the
/// server default (unbounded).
/// </summary>
public int? MaxDepth { get; init; }
/// <summary>
/// Galaxy category ids to include. Empty means all categories.
/// </summary>
public IReadOnlyList<int> CategoryIds { get; init; } = Array.Empty<int>();
/// <summary>
/// Template tag names that must appear somewhere in each returned
/// object's template chain. Empty means no template filter.
/// </summary>
public IReadOnlyList<string> TemplateChainContains { get; init; } = Array.Empty<string>();
/// <summary>
/// Optional glob (e.g. <c>"Tank*"</c>) matched against each object's tag name.
/// </summary>
public string? TagNameGlob { get; init; }
/// <summary>
/// When set, overrides whether each returned <c>GalaxyObject</c> includes
/// its dynamic attribute list. Leave null to use the server default.
/// </summary>
public bool? IncludeAttributes { get; init; }
/// <summary>
/// When true, restrict results to objects that bear at least one configured alarm.
/// </summary>
public bool AlarmBearingOnly { get; init; }
/// <summary>
/// When true, restrict results to objects that have at least one historized attribute.
/// </summary>
public bool HistorizedOnly { get; init; }
}
@@ -1,25 +0,0 @@
using MxGateway.Contracts;
namespace MxGateway.Client;
/// <summary>
/// Exposes the protocol versions compiled into this client package.
/// </summary>
public static class MxGatewayClientContractInfo
{
/// <summary>
/// Gets the gateway gRPC protocol version compiled into this client package.
/// A client and gateway are wire-compatible only when this value matches the
/// gateway's advertised gateway protocol version.
/// </summary>
public const uint GatewayProtocolVersion =
GatewayContractInfo.GatewayProtocolVersion;
/// <summary>
/// Gets the worker frame protocol version compiled into this client package.
/// Exposed for diagnostics so callers can report the worker protocol the
/// shared contracts were generated against.
/// </summary>
public const uint WorkerProtocolVersion =
GatewayContractInfo.WorkerProtocolVersion;
}
@@ -1,3 +0,0 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MxGateway.Client.Tests")]
@@ -1,55 +0,0 @@
using Grpc.Core;
namespace MxGateway.Client;
/// <summary>
/// Maps low-level <see cref="RpcException"/>s raised by the gRPC stack to the client's
/// native exception hierarchy. Shared by every gateway and Galaxy Repository transport
/// so the gRPC-to-native translation has exactly one implementation.
/// </summary>
internal static class RpcExceptionMapper
{
/// <summary>
/// Translates a <see cref="RpcException"/> into the most specific native exception type.
/// </summary>
/// <param name="exception">The gRPC exception to translate.</param>
/// <param name="cancellationToken">
/// The cancellation token of the originating call; used to distinguish a caller-driven
/// cancellation from a server-side <see cref="StatusCode.Cancelled"/> status.
/// </param>
/// <returns>
/// An <see cref="OperationCanceledException"/> when the call was cancelled, a typed
/// authentication/authorization exception for auth statuses, or an
/// <see cref="MxGatewayException"/> carrying the originating gRPC <see cref="StatusCode"/>.
/// </returns>
public static Exception Map(
RpcException exception,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(exception);
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
{
return new OperationCanceledException(
exception.Status.Detail,
exception,
cancellationToken);
}
return exception.StatusCode switch
{
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
exception.Status.Detail,
statusCode: exception.StatusCode,
innerException: exception),
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
exception.Status.Detail,
statusCode: exception.StatusCode,
innerException: exception),
_ => new MxGatewayException(
exception.Status.Detail,
exception.StatusCode,
exception),
};
}
}
+38 -81
View File
@@ -7,11 +7,11 @@ CLI, and unit tests.
| Project | Purpose | | Project | Purpose |
|---------|---------| |---------|---------|
| `MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. | | `ZB.MOM.WW.MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
| `MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. | | `ZB.MOM.WW.MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
| `MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. | | `ZB.MOM.WW.MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
The projects reference `src/MxGateway.Contracts/MxGateway.Contracts.csproj` so The projects reference `src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` so
the client compiles against the same generated protobuf and gRPC types as the the client compiles against the same generated protobuf and gRPC types as the
gateway. `clients/dotnet/generated` remains reserved for generator output if a gateway. `clients/dotnet/generated` remains reserved for generator output if a
future client build switches to client-local `Grpc.Tools` generation. future client build switches to client-local `Grpc.Tools` generation.
@@ -19,8 +19,8 @@ future client build switches to client-local `Grpc.Tools` generation.
## Build And Test ## Build And Test
```powershell ```powershell
dotnet build clients/dotnet/MxGateway.Client.sln dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx
dotnet test clients/dotnet/MxGateway.Client.sln --no-build dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx --no-build
``` ```
## Packaging ## Packaging
@@ -29,8 +29,8 @@ Create local library and CLI artifacts from the repository root:
```powershell ```powershell
$dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet' $dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet'
dotnet pack clients/dotnet/MxGateway.Client/MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput" dotnet pack clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput"
dotnet publish clients/dotnet/MxGateway.Client.Cli/MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet dotnet publish clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/ZB.MOM.WW.MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet
``` ```
The library package references the shared contracts project at build time. The The library package references the shared contracts project at build time. The
@@ -39,11 +39,11 @@ published CLI runs from `artifacts/clients/dotnet/mxgw-dotnet`.
## Regenerating Protobuf Bindings ## Regenerating Protobuf Bindings
The .NET client uses the generated C# types from The .NET client uses the generated C# types from
`src/MxGateway.Contracts/Generated`. Regenerate those files through the `src/ZB.MOM.WW.MxGateway.Contracts/Generated`. Regenerate those files through the
contracts project: contracts project:
```powershell ```powershell
dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
``` ```
## Client Usage ## Client Usage
@@ -84,47 +84,14 @@ messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
available, and command helpers have `*RawAsync` variants when callers need the available, and command helpers have `*RawAsync` variants when callers need the
complete `MxCommandReply`. complete `MxCommandReply`.
### Bulk Commands For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
the active alarms the gateway's central monitor currently holds),
The session exposes bulk variants for every command family that has one `StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
upstream — they all carry a list of entries in one gRPC round-trip, the worker keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
runs the per-item MXAccess calls sequentially on its STA, and the reply reference, optional comment, ack target). All three accept a cancellation
returns one result per requested entry. Per-entry failures populate token and pass through the `MxGateway:Alarms` configuration on the
`WasSuccessful = false` with the underlying HRESULT and never throw; only server — when alarms are disabled, the gateway returns an empty list / empty
protocol-level failures throw via `EnsureProtocolSuccess`. stream rather than failing.
```csharp
// Subscribe + Unsubscribe to a batch of tags in one round-trip
IReadOnlyList<SubscribeResult> subResults = await session.SubscribeBulkAsync(
serverHandle,
new[] { "Area001.Pump001.Speed", "Area001.Pump001.RunHours" });
int[] itemHandles = subResults.Where(r => r.WasSuccessful).Select(r => r.ItemHandle).ToArray();
await session.UnsubscribeBulkAsync(serverHandle, itemHandles);
// Bulk Write — sequential MXAccess Write per entry.
IReadOnlyList<BulkWriteResult> writeResults = await session.WriteBulkAsync(
serverHandle,
new[]
{
new WriteBulkEntry { ItemHandle = h1, UserId = 0, Value = 1.0.ToMxValue() },
new WriteBulkEntry { ItemHandle = h2, UserId = 0, Value = 2.0.ToMxValue() },
});
foreach (BulkWriteResult r in writeResults.Where(r => !r.WasSuccessful))
{
Console.Error.WriteLine($"item {r.ItemHandle}: {r.ErrorMessage}");
}
// Bulk Read — returns the cached OnDataChange value when the tag is already
// advised (was_cached = true) or takes a one-shot snapshot otherwise.
IReadOnlyList<BulkReadResult> readResults = await session.ReadBulkAsync(
serverHandle,
new[] { "Area001.Pump001.Speed", "Area001.Pump002.Speed" },
timeout: TimeSpan.FromMilliseconds(750));
```
`Write2BulkAsync`, `WriteSecuredBulkAsync`, and `WriteSecured2BulkAsync` follow
the same shape; the secured variants additionally carry `CurrentUserId` and
`VerifierUserId` per entry and require `invoke:secure` scope.
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return `MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
the first `CloseSessionReply` instead of sending another close request. the first `CloseSessionReply` instead of sending another close request.
@@ -154,38 +121,28 @@ can keep the full `MxCommandReply`, HRESULT, and status array when MXAccess
itself rejects a command. `MxAccessException.Reply` contains the raw generated itself rejects a command. `MxAccessException.Reply` contains the raw generated
reply. reply.
When a gRPC call itself fails, the transport maps the underlying
`RpcException` to a native exception: `Unauthenticated` becomes
`MxGatewayAuthenticationException`, `PermissionDenied` becomes
`MxGatewayAuthorizationException`, a cancelled call becomes
`OperationCanceledException`, and every other status becomes a base
`MxGatewayException`. `MxGatewayException.StatusCode` carries the originating
gRPC `Grpc.Core.StatusCode` (non-null whenever the failure came from a gRPC
status), so callers can distinguish a transient outage (`Unavailable`) from a
permanent error (`InvalidArgument`, `NotFound`) without downcasting
`InnerException`.
## CLI Usage ## CLI Usage
The test CLI supports deterministic JSON output for automation: The test CLI supports deterministic JSON output for automation:
```powershell ```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- version --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- version --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json
dotnet run --project clients/dotnet/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 -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --session-id <id> --max-messages 1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --session-id <id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --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
``` ```
`smoke` opens a session, registers a client, adds one item, advises it, `smoke` opens a session, registers a client, adds one item, advises it,
optionally writes a value when `--type` and `--value` are supplied, reads a optionally writes a value when `--type` and `--value` are supplied, reads a
bounded event stream, and closes the session in a `finally` block. CLI error bounded event stream, and closes the session in a `finally` block. CLI error
output redacts the effective API key, whether it was supplied through output redacts API keys supplied through `--api-key`.
`--api-key` or resolved from the `--api-key-env` environment variable.
## Galaxy Repository Browse ## Galaxy Repository Browse
@@ -234,9 +191,9 @@ IReadOnlyList<GalaxyObject> pumps = await repository.DiscoverHierarchyAsync(
The CLI exposes the same operations: The CLI exposes the same operations:
```powershell ```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
``` ```
### Watching deploy events ### Watching deploy events
@@ -271,15 +228,15 @@ await foreach (DeployEvent evt in repository.WatchDeployEventsAsync(
The CLI counterpart streams events until Ctrl+C (or `--max-events`): The CLI counterpart streams events until Ctrl+C (or `--max-events`):
```powershell ```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
``` ```
Use TLS options for a secured gateway: Use TLS options for a secured gateway:
```powershell ```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
``` ```
## Integration Checks ## Integration Checks
@@ -291,7 +248,7 @@ $env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'http://localhost:5000' $env:MXGATEWAY_ENDPOINT = 'http://localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>' $env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed' $env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
``` ```
## Related Documentation ## Related Documentation
@@ -1,6 +1,6 @@
using System.Globalization; using System.Globalization;
namespace MxGateway.Client.Cli; namespace ZB.MOM.WW.MxGateway.Client.Cli;
/// <summary>Parses command-line arguments into flags and named values.</summary> /// <summary>Parses command-line arguments into flags and named values.</summary>
internal sealed class CliArguments internal sealed class CliArguments
@@ -1,14 +1,8 @@
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli; namespace ZB.MOM.WW.MxGateway.Client.Cli;
/// <summary>
/// Minimal transport surface the CLI talks to. Exposes only the gateway and
/// Galaxy Repository RPCs the CLI needs so tests can substitute an in-process
/// fake without standing up a real gRPC channel. The production binding is a
/// thin adapter over <see cref="MxGatewayClient"/> and <see cref="GalaxyRepositoryClient"/>.
/// </summary>
public interface IMxGatewayCliClient : IAsyncDisposable public interface IMxGatewayCliClient : IAsyncDisposable
{ {
/// <summary> /// <summary>
@@ -1,8 +1,8 @@
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli; namespace ZB.MOM.WW.MxGateway.Client.Cli;
internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
{ {
@@ -1,4 +1,4 @@
namespace MxGateway.Client.Cli; namespace ZB.MOM.WW.MxGateway.Client.Cli;
/// <summary>Utility to redact API keys from error messages for safe output.</summary> /// <summary>Utility to redact API keys from error messages for safe output.</summary>
internal static class MxGatewayCliSecretRedactor internal static class MxGatewayCliSecretRedactor
@@ -1,11 +1,11 @@
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Google.Protobuf; using Google.Protobuf;
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli; namespace ZB.MOM.WW.MxGateway.Client.Cli;
/// <summary>Command-line interface for the MXAccess Gateway client, supporting session and command operations.</summary> /// <summary>Command-line interface for the MXAccess Gateway client, supporting session and command operations.</summary>
public static class MxGatewayClientCli public static class MxGatewayClientCli
@@ -16,6 +16,8 @@ public static class MxGatewayClientCli
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private const string BatchEndOfRecord = "__MXGW_BATCH_EOR__";
/// <summary>Runs the CLI synchronously with the given arguments, writing output and errors.</summary> /// <summary>Runs the CLI synchronously with the given arguments, writing output and errors.</summary>
/// <param name="args">Command-line arguments (command name followed by options).</param> /// <param name="args">Command-line arguments (command name followed by options).</param>
/// <param name="standardOutput">TextWriter for command output.</param> /// <param name="standardOutput">TextWriter for command output.</param>
@@ -55,8 +57,6 @@ public static class MxGatewayClientCli
standardInput ?? Console.In); standardInput ?? Console.In);
} }
private const string BatchEndOfRecord = "__MXGW_BATCH_EOR__";
private static async Task<int> RunCoreAsync( private static async Task<int> RunCoreAsync(
string[] args, string[] args,
TextWriter standardOutput, TextWriter standardOutput,
@@ -126,8 +126,6 @@ public static class MxGatewayClientCli
.ConfigureAwait(false), .ConfigureAwait(false),
"bench-read-bulk" => await BenchReadBulkAsync(arguments, client, standardOutput, cancellation.Token) "bench-read-bulk" => await BenchReadBulkAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false), .ConfigureAwait(false),
"bench-stream-events" => await BenchStreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token) "stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false), .ConfigureAwait(false),
"stream-alarms" => await StreamAlarmsAsync(arguments, client, standardOutput, cancellation.Token) "stream-alarms" => await StreamAlarmsAsync(arguments, client, standardOutput, cancellation.Token)
@@ -153,10 +151,7 @@ public static class MxGatewayClientCli
} }
catch (Exception exception) when (exception is not OperationCanceledException) catch (Exception exception) when (exception is not OperationCanceledException)
{ {
// Redact the effective API key — whether it came from --api-key or from string? apiKey = arguments.GetOptional("api-key");
// the (documented default) --api-key-env environment variable — so a
// transport error message that echoes the bearer token is never printed.
string? apiKey = TryResolveApiKey(arguments);
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey); string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
if (forceJsonErrors || arguments.HasFlag("json")) if (forceJsonErrors || arguments.HasFlag("json"))
@@ -174,88 +169,6 @@ public static class MxGatewayClientCli
} }
} }
private static IMxGatewayCliClient CreateDefaultClient(MxGatewayClientOptions options)
{
return new MxGatewayCliClientAdapter(MxGatewayClient.Create(options));
}
private static MxGatewayClientOptions CreateOptions(CliArguments arguments)
{
string endpoint = arguments.GetOptional("endpoint")
?? Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT")
?? "http://localhost:5000";
string apiKey = ResolveApiKey(arguments);
return new MxGatewayClientOptions
{
Endpoint = new Uri(endpoint, UriKind.Absolute),
ApiKey = apiKey,
UseTls = arguments.HasFlag("tls")
|| endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase),
DefaultCallTimeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30)),
ConnectTimeout = arguments.GetDuration("connect-timeout", TimeSpan.FromSeconds(10)),
CaCertificatePath = arguments.GetOptional("ca-file"),
ServerNameOverride = arguments.GetOptional("server-name"),
};
}
private static string ResolveApiKey(CliArguments arguments)
{
string? apiKey = TryResolveApiKey(arguments);
if (!string.IsNullOrWhiteSpace(apiKey))
{
return apiKey;
}
string apiKeyEnvironmentName = arguments.GetOptional("api-key-env")
?? "MXGATEWAY_API_KEY";
throw new ArgumentException(
$"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}.");
}
/// <summary>
/// Resolves the effective API key from <c>--api-key</c> or, failing that, the
/// environment variable named by <c>--api-key-env</c> (default
/// <c>MXGATEWAY_API_KEY</c>). Returns <see langword="null"/> when no key is
/// configured; used for redaction where a missing key must not throw.
/// </summary>
private static string? TryResolveApiKey(CliArguments arguments)
{
string? apiKey = arguments.GetOptional("api-key");
if (!string.IsNullOrWhiteSpace(apiKey))
{
return apiKey;
}
string apiKeyEnvironmentName = arguments.GetOptional("api-key-env")
?? "MXGATEWAY_API_KEY";
return Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
}
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
{
var cancellation = new CancellationTokenSource();
// Long-running streaming / bench commands run until they finish (or Ctrl+C)
// by default; a caller-supplied --timeout still applies if present. The
// bench commands default to --duration-seconds=30 --warmup-seconds=3 plus
// a per-session stagger, which already exceeds the default 30 s wall-clock
// budget, so applying that budget would cancel them mid-window and emit a
// zero-throughput JSON payload (see Client.Dotnet-015).
bool isLongRunning = command is "galaxy-watch" or "bench-read-bulk" or "bench-stream-events";
string? rawTimeout = arguments.GetOptional("timeout");
if (isLongRunning && string.IsNullOrWhiteSpace(rawTimeout))
{
return cancellation;
}
TimeSpan timeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30));
cancellation.CancelAfter(timeout);
return cancellation;
}
/// <summary> /// <summary>
/// Runs the CLI in batch mode: reads one command line at a time from /// Runs the CLI in batch mode: reads one command line at a time from
/// <paramref name="standardInput"/>, dispatches it through the normal /// <paramref name="standardInput"/>, dispatches it through the normal
@@ -303,9 +216,13 @@ public static class MxGatewayClientCli
forceJsonErrors: true) forceJsonErrors: true)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
catch (Exception exception) when (exception is not OperationCanceledException) catch (Exception exception)
{ {
// Unexpected exception that escaped RunCoreAsync (shouldn't happen, but be safe). // Unexpected exception that escaped RunCoreAsync (shouldn't happen, but be safe).
// OperationCanceledException from long-running streaming commands
// (e.g. galaxy-watch hit by --timeout) is caught here too — the
// batch process must continue with the next command rather than
// unwinding.
commandError.WriteLine(JsonSerializer.Serialize( commandError.WriteLine(JsonSerializer.Serialize(
new { error = exception.Message, type = exception.GetType().Name }, new { error = exception.Message, type = exception.GetType().Name },
JsonOptions)); JsonOptions));
@@ -332,6 +249,70 @@ public static class MxGatewayClientCli
} }
} }
private static IMxGatewayCliClient CreateDefaultClient(MxGatewayClientOptions options)
{
return new MxGatewayCliClientAdapter(MxGatewayClient.Create(options));
}
private static MxGatewayClientOptions CreateOptions(CliArguments arguments)
{
string endpoint = arguments.GetOptional("endpoint")
?? Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT")
?? "http://localhost:5000";
string apiKey = ResolveApiKey(arguments);
return new MxGatewayClientOptions
{
Endpoint = new Uri(endpoint, UriKind.Absolute),
ApiKey = apiKey,
UseTls = arguments.HasFlag("tls")
|| endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase),
DefaultCallTimeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30)),
ConnectTimeout = arguments.GetDuration("connect-timeout", TimeSpan.FromSeconds(10)),
CaCertificatePath = arguments.GetOptional("ca-file"),
ServerNameOverride = arguments.GetOptional("server-name"),
};
}
private static string ResolveApiKey(CliArguments arguments)
{
string? apiKey = arguments.GetOptional("api-key");
if (!string.IsNullOrWhiteSpace(apiKey))
{
return apiKey;
}
string apiKeyEnvironmentName = arguments.GetOptional("api-key-env")
?? "MXGATEWAY_API_KEY";
apiKey = Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
if (!string.IsNullOrWhiteSpace(apiKey))
{
return apiKey;
}
throw new ArgumentException(
$"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}.");
}
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
{
var cancellation = new CancellationTokenSource();
// Long-running streaming commands run until Ctrl+C / cancellation by default;
// a caller-supplied --timeout still applies if present.
bool isLongRunning = command is "galaxy-watch";
string? rawTimeout = arguments.GetOptional("timeout");
if (isLongRunning && string.IsNullOrWhiteSpace(rawTimeout))
{
return cancellation;
}
TimeSpan timeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30));
cancellation.CancelAfter(timeout);
return cancellation;
}
private static Task<int> OpenSessionAsync( private static Task<int> OpenSessionAsync(
CliArguments arguments, CliArguments arguments,
IMxGatewayCliClient client, IMxGatewayCliClient client,
@@ -682,6 +663,35 @@ public static class MxGatewayClientCli
cancellationToken); cancellationToken);
} }
/// <summary>
/// Parses the bulk-write CLI's <c>--values</c> list. All entries share
/// the single <c>--type</c> argument; the comma-separated values are
/// each parsed via <see cref="ParseValue(string, string)"/> on a per-entry basis.
/// This keeps the CLI simple for e2e use (one type, N values) — callers
/// that need heterogeneous types per entry should drive the library
/// directly.
/// </summary>
private static IReadOnlyList<MxValue> ParseValuesList(CliArguments arguments)
{
string type = arguments.GetRequired("type");
string[] values = ParseStringList(arguments.GetRequired("values")).ToArray();
MxValue[] result = new MxValue[values.Length];
for (int i = 0; i < values.Length; i++)
{
result[i] = ParseValue(type, values[i]);
}
return result;
}
private static void EnsureSameLength(int handles, int values)
{
if (handles != values)
{
throw new ArgumentException(
$"Bulk write requires the same number of --item-handles ({handles}) and --values ({values}).");
}
}
/// <summary> /// <summary>
/// Cross-language stress benchmark for ReadBulk. Opens its own session, /// Cross-language stress benchmark for ReadBulk. Opens its own session,
/// subscribes to N tags so the worker's MxAccessValueCache populates from /// subscribes to N tags so the worker's MxAccessValueCache populates from
@@ -732,7 +742,7 @@ public static class MxGatewayClientCli
}), }),
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
int serverHandle = RequireRegisterServerHandle(registerReply); int serverHandle = registerReply.Register?.ServerHandle ?? registerReply.ReturnValue.Int32Value;
SubscribeBulkCommand subscribe = new() { ServerHandle = serverHandle }; SubscribeBulkCommand subscribe = new() { ServerHandle = serverHandle };
subscribe.TagAddresses.Add(tags); subscribe.TagAddresses.Add(tags);
@@ -886,295 +896,6 @@ public static class MxGatewayClientCli
} }
} }
/// <summary>
/// Single-client event-rate stress benchmark. Opens its own session,
/// subscribes to <c>--bulk-size</c> tags spanning the dev galaxy's
/// TestMachine_NNN.* set, then drains the gateway's StreamEvents
/// server-stream as fast as it can for <c>--duration-seconds</c>.
/// Tracks events received per second, end-to-end latency from
/// <c>event.worker_timestamp</c> to client receive time, and any
/// worker faults emitted by the gateway over the same window.
/// <para>
/// The companion <c>--all-attributes</c> flag (default <c>true</c>)
/// subscribes to all six TestMachine attributes per machine number
/// so the bench can drive event volume past one-attribute-per-machine
/// when the dev galaxy's TestChangingInt is the only churning tag.
/// </para>
/// </summary>
private static async Task<int> BenchStreamEventsAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
int durationSeconds = arguments.GetInt32("duration-seconds", 30);
int warmupSeconds = arguments.GetInt32("warmup-seconds", 3);
int bulkSize = arguments.GetInt32("bulk-size", 60);
int tagStart = arguments.GetInt32("tag-start", 1);
int sessionCount = arguments.GetInt32("session-count", 1);
// Concurrent OpenSession calls all the way up the stack force the
// gateway to spawn N x86 workers simultaneously; that hits the
// 30-second worker startup timeout around 6-8 concurrent opens on
// a dev rig. The stagger spreads the opens out so per-session
// OpenSession+Register completes inside that budget before the
// next session starts spawning. The steady-state event window
// begins only once all sessions are open and subscribed.
int sessionStartStaggerMs = arguments.GetInt32("session-start-stagger-ms", sessionCount > 1 ? 750 : 0);
string tagPrefix = arguments.GetOptional("tag-prefix") ?? "TestMachine_";
bool allAttributes = !arguments.HasFlag("single-attribute");
string singleAttribute = arguments.GetOptional("tag-attribute") ?? "TestChangingInt";
string clientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-bench-events";
if (sessionCount < 1)
{
throw new ArgumentException("--session-count must be >= 1.");
}
// Build the tag set. With --all-attributes (default) we rotate through
// all six TestMachine attributes to drive more event volume from a
// smaller machine range; with --single-attribute we use the same
// attribute on contiguous machine numbers (the original bench-read-
// bulk shape).
string[] attributes = allAttributes
? new[] { "TestChangingInt", "ProtectedValue", "TestBoolArray[]", "TestIntArray[]", "TestDateTimeArray[]", "TestStringArray[]" }
: new[] { singleAttribute };
List<string> tags = new(capacity: bulkSize);
for (int i = 0; i < bulkSize; i++)
{
int machineNumber = tagStart + (i / attributes.Length);
string attribute = attributes[i % attributes.Length];
tags.Add($"{tagPrefix}{machineNumber:D3}.{attribute}");
}
long warmupEvents = 0;
long steadyEvents = 0;
long steadyDataChangeEvents = 0;
List<double> endToEndLatencyMs = new(capacity: 262_144);
object latencyLock = new();
DateTime? firstSteadyEventUtc = null;
DateTime? lastSteadyEventUtc = null;
int totalSubscribeFailures = 0;
int totalSubscribedTagCount = 0;
int totalDrainedFaultCount = 0;
DateTime warmupStart = default;
DateTime warmupEnd = default;
DateTime steadyEnd = default;
// Phase 1: open + subscribe each session sequentially with a stagger,
// so worker spawn-up doesn't exceed the gateway's per-session startup
// timeout. Each session stashes its (sessionId, serverHandle, itemHandles)
// for phase 2.
var openedSessions = new List<(string SessionId, int ServerHandle, int[] ItemHandles, string ClientName)>(sessionCount);
for (int sessionIndex = 0; sessionIndex < sessionCount; sessionIndex++)
{
if (sessionIndex > 0 && sessionStartStaggerMs > 0)
{
await Task.Delay(sessionStartStaggerMs, cancellationToken).ConfigureAwait(false);
}
string thisClientName = sessionCount == 1
? clientName
: $"{clientName}-{sessionIndex:D2}";
OpenSessionReply openReply = await client.OpenSessionAsync(
new OpenSessionRequest { ClientSessionName = thisClientName, ClientCorrelationId = CreateCorrelationId() },
cancellationToken)
.ConfigureAwait(false);
string sessionId = openReply.SessionId;
MxCommandReply registerReply = await InvokeAndEnsureAsync(
client,
CreateCommandRequest(sessionId, new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand { ClientName = thisClientName },
}),
cancellationToken)
.ConfigureAwait(false);
int serverHandle = RequireRegisterServerHandle(registerReply);
SubscribeBulkCommand subscribe = new() { ServerHandle = serverHandle };
subscribe.TagAddresses.Add(tags);
MxCommandReply subscribeReply = await InvokeAndEnsureAsync(
client,
CreateCommandRequest(sessionId, new MxCommand
{
Kind = MxCommandKind.SubscribeBulk,
SubscribeBulk = subscribe,
}),
cancellationToken)
.ConfigureAwait(false);
SubscribeResult[] subscribeResults = subscribeReply.SubscribeBulk?.Results.ToArray() ?? [];
int[] itemHandles = subscribeResults.Where(r => r.WasSuccessful).Select(r => r.ItemHandle).ToArray();
totalSubscribedTagCount += itemHandles.Length;
totalSubscribeFailures += subscribeResults.Count(r => !r.WasSuccessful);
openedSessions.Add((sessionId, serverHandle, itemHandles, thisClientName));
}
// Phase 2: now every session is open + advised. Start the measurement
// window and run each session's StreamEvents reader in parallel.
warmupStart = DateTime.UtcNow;
warmupEnd = warmupStart + TimeSpan.FromSeconds(warmupSeconds);
steadyEnd = warmupEnd + TimeSpan.FromSeconds(durationSeconds);
async Task RunStreamAsync((string SessionId, int ServerHandle, int[] ItemHandles, string ClientName) ctx)
{
using CancellationTokenSource streamCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task streamTask = Task.Run(async () =>
{
StreamEventsRequest streamRequest = new() { SessionId = ctx.SessionId };
await foreach (MxEvent mxEvent in client.StreamEventsAsync(streamRequest, streamCts.Token)
.ConfigureAwait(false))
{
DateTime nowUtc = DateTime.UtcNow;
if (nowUtc >= steadyEnd)
{
break;
}
if (nowUtc < warmupEnd)
{
Interlocked.Increment(ref warmupEvents);
continue;
}
// Guarded by latencyLock so parallel sessions can't tear a 64-bit
// DateTime? read or stomp an already-set firstSteadyEventUtc with
// a later timestamp from a slower-to-start session. The lock is
// already held by the latency append a few lines below, so the
// extra cost is one uncontended lock acquisition per event.
lock (latencyLock)
{
firstSteadyEventUtc ??= nowUtc;
lastSteadyEventUtc = nowUtc;
}
Interlocked.Increment(ref steadyEvents);
if (mxEvent.Family == MxEventFamily.OnDataChange)
{
Interlocked.Increment(ref steadyDataChangeEvents);
}
if (mxEvent.WorkerTimestamp is { } workerStamp)
{
double latency = (nowUtc - workerStamp.ToDateTime()).TotalMilliseconds;
if (latency >= 0)
{
lock (latencyLock) { endToEndLatencyMs.Add(latency); }
}
}
}
}, streamCts.Token);
// The inner streamTask MUST be observed on every path — including when
// the outer cancellationToken cancels during the Task.Delay below — or
// its fault surfaces as a TaskScheduler.UnobservedTaskException after
// GC. Use try/finally so the cancel + await pair always runs (see
// Client.Dotnet-016). RpcException(Cancelled) never reaches here in
// production because GrpcMxGatewayClientTransport.StreamEventsAsync
// routes through RpcExceptionMapper.Map, which returns OCE for
// StatusCode.Cancelled.
try
{
await Task.Delay(steadyEnd - warmupStart, cancellationToken).ConfigureAwait(false);
}
finally
{
streamCts.Cancel();
try { await streamTask.ConfigureAwait(false); }
catch (OperationCanceledException) { }
catch (MxGatewayException) { }
}
try
{
MxCommandReply drainReply = await client.InvokeAsync(
CreateCommandRequest(ctx.SessionId, new MxCommand
{
Kind = MxCommandKind.DrainEvents,
DrainEvents = new DrainEventsCommand { MaxEvents = 16 },
}),
cancellationToken)
.ConfigureAwait(false);
Interlocked.Add(ref totalDrainedFaultCount, drainReply.DrainEvents?.Events.Count ?? 0);
}
catch { /* fault probe is best-effort */ }
if (ctx.ItemHandles.Length > 0)
{
try
{
UnsubscribeBulkCommand unsubscribe = new() { ServerHandle = ctx.ServerHandle };
unsubscribe.ItemHandles.Add(ctx.ItemHandles);
_ = await client.InvokeAsync(
CreateCommandRequest(ctx.SessionId, new MxCommand
{
Kind = MxCommandKind.UnsubscribeBulk,
UnsubscribeBulk = unsubscribe,
}),
cancellationToken)
.ConfigureAwait(false);
}
catch { /* best-effort */ }
}
try
{
await client.CloseSessionAsync(
new CloseSessionRequest { SessionId = ctx.SessionId, ClientCorrelationId = CreateCorrelationId() },
cancellationToken)
.ConfigureAwait(false);
}
catch { /* best-effort */ }
}
Task[] streamTasks = openedSessions.Select(RunStreamAsync).ToArray();
await Task.WhenAll(streamTasks).ConfigureAwait(false);
double steadyElapsedSeconds = (firstSteadyEventUtc.HasValue && lastSteadyEventUtc.HasValue)
? (lastSteadyEventUtc.Value - firstSteadyEventUtc.Value).TotalSeconds
: 0;
double eventsPerSecond = steadyElapsedSeconds > 0 ? steadyEvents / steadyElapsedSeconds : 0;
double dataChangeEventsPerSecond = steadyElapsedSeconds > 0
? steadyDataChangeEvents / steadyElapsedSeconds
: 0;
double[] latencySnapshot;
lock (latencyLock) { latencySnapshot = endToEndLatencyMs.ToArray(); }
object stats = new
{
language = "dotnet",
command = "bench-stream-events",
endpoint = arguments.GetOptional("endpoint") ?? "(default)",
clientName,
sessionCount,
bulkSize,
durationSeconds,
warmupSeconds,
allAttributes,
steadyElapsedSeconds = Math.Round(steadyElapsedSeconds, 3),
subscribedTagCount = totalSubscribedTagCount,
subscribeFailures = totalSubscribeFailures,
warmupEvents,
steadyEvents,
steadyDataChangeEvents,
eventsPerSecond = Math.Round(eventsPerSecond, 2),
dataChangeEventsPerSecond = Math.Round(dataChangeEventsPerSecond, 2),
drainedFaultCount = totalDrainedFaultCount,
endToEndLatencyMs = new
{
p50 = Percentile(latencySnapshot, 0.50),
p95 = Percentile(latencySnapshot, 0.95),
p99 = Percentile(latencySnapshot, 0.99),
max = latencySnapshot.Length > 0 ? Math.Round(latencySnapshot.Max(), 3) : 0,
mean = latencySnapshot.Length > 0 ? Math.Round(latencySnapshot.Average(), 3) : 0,
sampleCount = latencySnapshot.Length,
},
};
output.WriteLine(JsonSerializer.Serialize(stats, JsonOptions));
return 0;
}
/// <summary> /// <summary>
/// Computes the requested percentile from an unsorted latency sample using /// Computes the requested percentile from an unsorted latency sample using
/// nearest-rank with linear interpolation. Rounds to 3 decimal places to /// nearest-rank with linear interpolation. Rounds to 3 decimal places to
@@ -1202,35 +923,6 @@ public static class MxGatewayClientCli
return Math.Round(value, 3); return Math.Round(value, 3);
} }
/// <summary>
/// Parses the bulk-write CLI's <c>--values</c> list. All entries share
/// the single <c>--type</c> argument; the comma-separated values are
/// each parsed via <see cref="ParseValue"/> on a per-entry basis. This
/// keeps the CLI simple for e2e use (one type, N values) — callers
/// that need heterogeneous types per entry should drive the library
/// directly.
/// </summary>
private static IReadOnlyList<MxValue> ParseValuesList(CliArguments arguments)
{
string type = arguments.GetRequired("type");
string[] values = ParseStringList(arguments.GetRequired("values")).ToArray();
MxValue[] result = new MxValue[values.Length];
for (int i = 0; i < values.Length; i++)
{
result[i] = ParseValue(type, values[i]);
}
return result;
}
private static void EnsureSameLength(int handles, int values)
{
if (handles != values)
{
throw new ArgumentException(
$"Bulk write requires the same number of --item-handles ({handles}) and --values ({values}).");
}
}
private static Task<int> WriteAsync( private static Task<int> WriteAsync(
CliArguments arguments, CliArguments arguments,
IMxGatewayCliClient client, IMxGatewayCliClient client,
@@ -1337,14 +1029,8 @@ public static class MxGatewayClientCli
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ {
// Client.Dotnet-017: the supplied cancellation token covers both the // Client.Dotnet-017: graceful end-of-window completion mode for a
// user's --timeout wall-clock budget (via CreateCancellation's // finite-window event collector. Emit aggregate JSON below and exit 0.
// CancelAfter) and external Ctrl+C / parent CTS cancellation. All
// three are graceful completion modes for a finite-window event
// collector: emit the events that arrived before the window closed
// and exit 0. The events list is well-formed at this point; the
// aggregate JSON below still runs. This matches how the Go, Rust,
// Python, and Java CLIs treat their equivalent timeouts.
} }
if (json && !jsonLines) if (json && !jsonLines)
@@ -1506,7 +1192,7 @@ public static class MxGatewayClientCli
Kind = MxCommandKind.Register, Kind = MxCommandKind.Register,
Register = new RegisterCommand { ClientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-smoke" }, Register = new RegisterCommand { ClientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-smoke" },
}, },
RequireRegisterServerHandle, reply => reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value,
commandReplies, commandReplies,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -1524,7 +1210,7 @@ public static class MxGatewayClientCli
ItemDefinition = arguments.GetRequired("item"), ItemDefinition = arguments.GetRequired("item"),
}, },
}, },
RequireAddItemItemHandle, reply => reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value,
commandReplies, commandReplies,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -1656,41 +1342,6 @@ public static class MxGatewayClientCli
return reply; return reply;
} }
/// <summary>
/// Returns the server handle from a successful <c>register</c> reply, or throws
/// <see cref="MxGatewayException"/> when the typed <see cref="MxCommandReply.Register"/>
/// payload is absent. Mirrors the SDK-level <see cref="MxGatewaySession.RegisterAsync"/>
/// contract: a successful reply without the typed payload is a gateway protocol
/// error, not a license to fall through to <c>ReturnValue.Int32Value</c> (which is 0
/// when the reply carries no return value).
/// </summary>
private static int RequireRegisterServerHandle(MxCommandReply reply)
{
return reply.Register?.ServerHandle
?? throw CreateMissingPayloadException(reply, "register");
}
/// <summary>
/// Returns the item handle from a successful <c>add_item</c> reply, or throws
/// <see cref="MxGatewayException"/> when the typed <see cref="MxCommandReply.AddItem"/>
/// payload is absent. See <see cref="RequireRegisterServerHandle"/> for the rationale.
/// </summary>
private static int RequireAddItemItemHandle(MxCommandReply reply)
{
return reply.AddItem?.ItemHandle
?? throw CreateMissingPayloadException(reply, "add_item");
}
private static MxGatewayException CreateMissingPayloadException(
MxCommandReply reply,
string expectedPayload)
{
return new MxGatewayException(
$"Gateway reply for command kind={reply.Kind} reported success but is missing "
+ $"the required '{expectedPayload}' payload; cannot resolve a handle. "
+ $"session={reply.SessionId}; correlation={reply.CorrelationId}");
}
private static MxCommandRequest CreateCommandRequest( private static MxCommandRequest CreateCommandRequest(
string sessionId, string sessionId,
MxCommand command) MxCommand command)
@@ -1787,12 +1438,12 @@ public static class MxGatewayClientCli
return ParseValue(arguments.GetRequired("type"), arguments.GetRequired("value")); return ParseValue(arguments.GetRequired("type"), arguments.GetRequired("value"));
} }
private static MxValue ParseValue(string typeName, string value) private static MxValue ParseValue(string type, string value)
{ {
string type = typeName.ToLowerInvariant(); string normalisedType = type.ToLowerInvariant();
string[] values = value.Split(',', StringSplitOptions.TrimEntries); string[] values = value.Split(',', StringSplitOptions.TrimEntries);
return type switch return normalisedType switch
{ {
"bool" or "boolean" => bool.Parse(value).ToMxValue(), "bool" or "boolean" => bool.Parse(value).ToMxValue(),
"bool-array" or "boolean-array" => values.Select(bool.Parse).ToArray().ToMxValue(), "bool-array" or "boolean-array" => values.Select(bool.Parse).ToArray().ToMxValue(),
@@ -1811,7 +1462,7 @@ public static class MxGatewayClientCli
.Select(item => DateTimeOffset.Parse(item, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal)) .Select(item => DateTimeOffset.Parse(item, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal))
.ToArray() .ToArray()
.ToMxValue(), .ToMxValue(),
_ => throw new ArgumentException($"Unsupported MX value type '{type}'."), _ => throw new ArgumentException($"Unsupported MX value type '{normalisedType}'."),
}; };
} }
@@ -2028,7 +1679,6 @@ public static class MxGatewayClientCli
or "write-secured-bulk" or "write-secured-bulk"
or "write-secured2-bulk" or "write-secured2-bulk"
or "bench-read-bulk" or "bench-read-bulk"
or "bench-stream-events"
or "stream-events" or "stream-events"
or "stream-alarms" or "stream-alarms"
or "acknowledge-alarm" or "acknowledge-alarm"
@@ -2082,13 +1732,14 @@ public static class MxGatewayClientCli
writer.WriteLine("mxgw-dotnet register --session-id <id> --client-name <name> [--json]"); writer.WriteLine("mxgw-dotnet register --session-id <id> --client-name <name> [--json]");
writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]"); writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]");
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]"); writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]"); writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]");
writer.WriteLine("mxgw-dotnet write-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--user-id <n>] [--json]"); writer.WriteLine("mxgw-dotnet write-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--user-id <n>] [--json]");
writer.WriteLine("mxgw-dotnet write2-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--timestamp <iso>] [--user-id <n>] [--json]"); writer.WriteLine("mxgw-dotnet write2-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--timestamp <iso>] [--user-id <n>] [--json]");
writer.WriteLine("mxgw-dotnet write-secured-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> --current-user-id <n> [--verifier-user-id <n>] [--json]"); writer.WriteLine("mxgw-dotnet write-secured-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> --current-user-id <n> [--verifier-user-id <n>] [--json]");
writer.WriteLine("mxgw-dotnet write-secured2-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--timestamp <iso>] --current-user-id <n> [--verifier-user-id <n>] [--json]"); writer.WriteLine("mxgw-dotnet write-secured2-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> --current-user-id <n> [--verifier-user-id <n>] [--timestamp <iso>] [--json]");
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]"); 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 unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]"); writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]"); writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]"); writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
@@ -1,3 +1,3 @@
using MxGateway.Client.Cli; using ZB.MOM.WW.MxGateway.Client.Cli;
return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error); return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error);
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" /> <ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
@@ -1,7 +1,7 @@
using Grpc.Core; using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary> /// <summary>
/// Fake Galaxy Repository client transport for testing. /// Fake Galaxy Repository client transport for testing.
@@ -1,7 +1,7 @@
using Grpc.Core; using Grpc.Core;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary> /// <summary>
/// Fake implementation of IMxGatewayClientTransport for testing. /// Fake implementation of IMxGatewayClientTransport for testing.
@@ -46,6 +46,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// </summary> /// </summary>
public List<(AcknowledgeAlarmRequest Request, CallOptions CallOptions)> AcknowledgeAlarmCalls { get; } = []; public List<(AcknowledgeAlarmRequest Request, CallOptions CallOptions)> AcknowledgeAlarmCalls { get; } = [];
/// <summary>
/// Gets the list of captured QueryActiveAlarmsAsync calls.
/// </summary>
public List<(QueryActiveAlarmsRequest Request, CallOptions CallOptions)> QueryActiveAlarmsCalls { get; } = [];
/// <summary> /// <summary>
/// Gets the list of captured StreamAlarmsAsync calls. /// Gets the list of captured StreamAlarmsAsync calls.
/// </summary> /// </summary>
@@ -58,6 +63,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new(); private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new();
private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = []; private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = [];
private readonly List<AlarmFeedMessage> _alarmFeedMessages = [];
/// <summary> /// <summary>
/// Gets or sets the reply to return from OpenSessionAsync. /// Gets or sets the reply to return from OpenSessionAsync.
@@ -91,19 +97,6 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// </summary> /// </summary>
public Queue<Exception> CloseSessionExceptions { get; } = new(); public Queue<Exception> CloseSessionExceptions { get; } = new();
/// <summary>
/// Gets or sets a value indicating whether thrown <see cref="RpcException"/>s are mapped
/// to <see cref="MxGatewayException"/> the way the production gRPC transport does. Lets
/// retry tests exercise the wrapped-exception predicate branch that runs in production.
/// </summary>
public bool MapTransportExceptions { get; set; }
/// <summary>
/// Gets or sets an optional hook awaited inside CloseSessionAsync after the call is
/// recorded; lets tests pause a close mid-flight to observe concurrent dispose.
/// </summary>
public Func<Task>? CloseSessionHook { get; set; }
/// <summary> /// <summary>
/// Gets the queue of exceptions to throw from InvokeAsync. /// Gets the queue of exceptions to throw from InvokeAsync.
/// </summary> /// </summary>
@@ -121,7 +114,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
OpenSessionCalls.Add((request, callOptions)); OpenSessionCalls.Add((request, callOptions));
if (OpenSessionExceptions.TryDequeue(out Exception? exception)) if (OpenSessionExceptions.TryDequeue(out Exception? exception))
{ {
throw Translate(exception, callOptions); throw exception;
} }
return Task.FromResult(OpenSessionReply); return Task.FromResult(OpenSessionReply);
@@ -132,23 +125,17 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// </summary> /// </summary>
/// <param name="request">The CloseSessionRequest to process.</param> /// <param name="request">The CloseSessionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param> /// <param name="callOptions">Call options specifying RPC behavior.</param>
public async Task<CloseSessionReply> CloseSessionAsync( public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request, CloseSessionRequest request,
CallOptions callOptions) CallOptions callOptions)
{ {
CloseSessionCalls.Add((request, callOptions)); CloseSessionCalls.Add((request, callOptions));
if (CloseSessionHook is not null)
{
await CloseSessionHook().ConfigureAwait(false);
}
if (CloseSessionExceptions.TryDequeue(out Exception? exception)) if (CloseSessionExceptions.TryDequeue(out Exception? exception))
{ {
throw Translate(exception, callOptions); throw exception;
} }
return CloseSessionReply; return Task.FromResult(CloseSessionReply);
} }
/// <summary> /// <summary>
@@ -163,7 +150,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
InvokeCalls.Add((request, callOptions)); InvokeCalls.Add((request, callOptions));
if (InvokeExceptions.TryDequeue(out Exception? exception)) if (InvokeExceptions.TryDequeue(out Exception? exception))
{ {
throw Translate(exception, callOptions); throw exception;
} }
return Task.FromResult(_invokeReplies.Dequeue()); return Task.FromResult(_invokeReplies.Dequeue());
@@ -216,7 +203,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
AcknowledgeAlarmCalls.Add((request, callOptions)); AcknowledgeAlarmCalls.Add((request, callOptions));
if (AcknowledgeAlarmExceptions.TryDequeue(out Exception? exception)) if (AcknowledgeAlarmExceptions.TryDequeue(out Exception? exception))
{ {
throw Translate(exception, callOptions); throw exception;
} }
return Task.FromResult(_acknowledgeReplies.Count > 0 return Task.FromResult(_acknowledgeReplies.Count > 0
@@ -230,23 +217,20 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
} }
/// <summary> /// <summary>
/// Records the call and yields each enqueued snapshot as an active-alarm /// Records the query call and yields each enqueued snapshot.
/// feed message, then a snapshot-complete sentinel.
/// </summary> /// </summary>
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync( public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
StreamAlarmsRequest request, QueryActiveAlarmsRequest request,
CallOptions callOptions) CallOptions callOptions)
{ {
StreamAlarmsCalls.Add((request, callOptions)); QueryActiveAlarmsCalls.Add((request, callOptions));
foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots) foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots)
{ {
callOptions.CancellationToken.ThrowIfCancellationRequested(); callOptions.CancellationToken.ThrowIfCancellationRequested();
await Task.Yield(); await Task.Yield();
yield return new AlarmFeedMessage { ActiveAlarm = snapshot }; yield return snapshot;
} }
yield return new AlarmFeedMessage { SnapshotComplete = true };
} }
/// <summary>Enqueues an acknowledge reply.</summary> /// <summary>Enqueues an acknowledge reply.</summary>
@@ -255,23 +239,32 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
_acknowledgeReplies.Enqueue(reply); _acknowledgeReplies.Enqueue(reply);
} }
/// <summary>Enqueues a snapshot yielded from StreamAlarmsAsync as an active-alarm message.</summary> /// <summary>Enqueues a snapshot to be yielded from QueryActiveAlarmsAsync.</summary>
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot) public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
{ {
_activeAlarmSnapshots.Add(snapshot); _activeAlarmSnapshots.Add(snapshot);
} }
/// <summary> /// <summary>
/// Maps a queued exception the way the production gRPC transport does when /// Records the stream-alarms call and yields each enqueued feed message.
/// <see cref="MapTransportExceptions"/> is set; otherwise returns it unchanged.
/// </summary> /// </summary>
private Exception Translate(Exception exception, CallOptions callOptions) public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CallOptions callOptions)
{ {
if (MapTransportExceptions && exception is RpcException rpcException) StreamAlarmsCalls.Add((request, callOptions));
foreach (AlarmFeedMessage message in _alarmFeedMessages)
{ {
return RpcExceptionMapper.Map(rpcException, callOptions.CancellationToken); callOptions.CancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return message;
}
} }
return exception; /// <summary>Enqueues an alarm feed message to be yielded from StreamAlarmsAsync.</summary>
public void AddAlarmFeedMessage(AlarmFeedMessage message)
{
_alarmFeedMessages.Add(message);
} }
} }
@@ -1,8 +1,8 @@
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Grpc.Core; using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class GalaxyRepositoryClientTests public sealed class GalaxyRepositoryClientTests
{ {
@@ -1,8 +1,8 @@
using Google.Protobuf; using Google.Protobuf;
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxCommandReplyExtensionsTests public sealed class MxCommandReplyExtensionsTests
{ {
@@ -1,13 +1,13 @@
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Grpc.Core; using Grpc.Core;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary> /// <summary>
/// Pins the .NET SDK surface for the alarm RPCs: /// PR E.2 — pins the .NET SDK surface for the new alarm RPCs:
/// <see cref="MxGatewayClient.AcknowledgeAlarmAsync"/> and /// <see cref="MxGatewayClient.AcknowledgeAlarmAsync"/> and
/// <see cref="MxGatewayClient.StreamAlarmsAsync"/>. /// <see cref="MxGatewayClient.QueryActiveAlarmsAsync"/>.
/// </summary> /// </summary>
public sealed class MxGatewayClientAlarmsTests public sealed class MxGatewayClientAlarmsTests
{ {
@@ -70,17 +70,19 @@ public sealed class MxGatewayClientAlarmsTests
} }
[Fact] [Fact]
public async Task AcknowledgeAlarmAsync_SurfacesRpcExceptionFromFakeTransportVerbatim_WhenMappingDisabled() public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
{ {
// Default FakeGatewayTransport.MapTransportExceptions is false, matching the
// historical pass-through shape: a thrown RpcException reaches the caller as
// RpcException rather than being mapped to a typed MxGatewayException. This
// test pins that shape so a future change can't silently flip it.
FakeGatewayTransport transport = CreateTransport(); FakeGatewayTransport transport = CreateTransport();
transport.AcknowledgeAlarmExceptions.Enqueue( transport.AcknowledgeAlarmExceptions.Enqueue(
new RpcException(new Status(StatusCode.Unauthenticated, "expired key"))); new RpcException(new Status(StatusCode.Unauthenticated, "expired key")));
await using MxGatewayClient client = CreateClient(transport); await using MxGatewayClient client = CreateClient(transport);
// Note: the FakeGatewayTransport surfaces RpcException directly (it does not run
// through GrpcMxGatewayClientTransport's mapping); the fake's contract here is to
// pass the exception verbatim. RpcException → typed exception mapping is covered
// in the GrpcMxGatewayClientTransport-level tests; the SDK-level test pins the
// pass-through shape so a future migration to direct mapping won't silently
// change observable behaviour.
var ex = await Assert.ThrowsAsync<RpcException>( var ex = await Assert.ThrowsAsync<RpcException>(
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest () => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
{ {
@@ -92,72 +94,50 @@ public sealed class MxGatewayClientAlarmsTests
} }
[Fact] [Fact]
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException() public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
{
// Production parity: GrpcMxGatewayClientTransport.AcknowledgeAlarmAsync runs
// every thrown RpcException through RpcExceptionMapper.Map, so callers see
// MxGatewayAuthenticationException (for Unauthenticated) rather than the raw
// RpcException. The fake transport reproduces that mapping when
// MapTransportExceptions is set, letting this SDK-level test cover the same
// observable behaviour without standing up a real gRPC channel.
FakeGatewayTransport transport = CreateTransport();
transport.MapTransportExceptions = true;
transport.AcknowledgeAlarmExceptions.Enqueue(
new RpcException(new Status(StatusCode.Unauthenticated, "expired key")));
await using MxGatewayClient client = CreateClient(transport);
var ex = await Assert.ThrowsAsync<MxGatewayAuthenticationException>(
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
{
AlarmFullReference = "Tank01.Level.HiHi",
Comment = string.Empty,
OperatorUser = "alice",
}));
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
}
[Fact]
public async Task StreamAlarmsAsync_StreamsSnapshotThenSnapshotComplete()
{ {
FakeGatewayTransport transport = CreateTransport(); FakeGatewayTransport transport = CreateTransport();
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active)); transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked)); transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked));
await using MxGatewayClient client = CreateClient(transport); await using MxGatewayClient client = CreateClient(transport);
List<AlarmFeedMessage> messages = []; List<ActiveAlarmSnapshot> snapshots = [];
await foreach (AlarmFeedMessage message in client.StreamAlarmsAsync(new StreamAlarmsRequest())) await foreach (ActiveAlarmSnapshot snapshot in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest
{ {
messages.Add(message); SessionId = "session-fixture",
}))
{
snapshots.Add(snapshot);
} }
Assert.Equal(3, messages.Count); Assert.Equal(2, snapshots.Count);
Assert.Equal("Tank01.Level.HiHi", messages[0].ActiveAlarm.AlarmFullReference); Assert.Equal("Tank01.Level.HiHi", snapshots[0].AlarmFullReference);
Assert.Equal(AlarmConditionState.Active, messages[0].ActiveAlarm.CurrentState); Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
Assert.Equal(AlarmConditionState.ActiveAcked, messages[1].ActiveAlarm.CurrentState); Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState);
Assert.True(messages[2].SnapshotComplete); Assert.Single(transport.QueryActiveAlarmsCalls);
Assert.Single(transport.StreamAlarmsCalls);
} }
[Fact] [Fact]
public async Task StreamAlarmsAsync_PassesFilterPrefix() public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
{ {
FakeGatewayTransport transport = CreateTransport(); FakeGatewayTransport transport = CreateTransport();
await using MxGatewayClient client = CreateClient(transport); await using MxGatewayClient client = CreateClient(transport);
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(new StreamAlarmsRequest await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest
{ {
SessionId = "session-fixture",
AlarmFilterPrefix = "Tank01.", AlarmFilterPrefix = "Tank01.",
})) }))
{ {
// only the snapshot-complete sentinel; verifying the request passes through // no snapshots enqueued; just verifying the request passes through
} }
var call = Assert.Single(transport.StreamAlarmsCalls); var call = Assert.Single(transport.QueryActiveAlarmsCalls);
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix); Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
} }
[Fact] [Fact]
public async Task StreamAlarmsAsync_HonorsCancellationDuringEnumeration() public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
{ {
FakeGatewayTransport transport = CreateTransport(); FakeGatewayTransport transport = CreateTransport();
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active)); transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
@@ -167,8 +147,8 @@ public sealed class MxGatewayClientAlarmsTests
using CancellationTokenSource cancellation = new(); using CancellationTokenSource cancellation = new();
await Assert.ThrowsAsync<OperationCanceledException>(async () => await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{ {
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync( await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(
new StreamAlarmsRequest(), new QueryActiveAlarmsRequest { SessionId = "session-fixture" },
cancellation.Token)) cancellation.Token))
{ {
cancellation.Cancel(); cancellation.Cancel();
@@ -1,9 +1,9 @@
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using MxGateway.Client.Cli; using ZB.MOM.WW.MxGateway.Client.Cli;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>Tests for the CLI command interface.</summary> /// <summary>Tests for the CLI command interface.</summary>
public sealed class MxGatewayClientCliTests public sealed class MxGatewayClientCliTests
@@ -106,43 +106,6 @@ public sealed class MxGatewayClientCliTests
Assert.Contains("[redacted]", error.ToString()); Assert.Contains("[redacted]", error.ToString());
} }
/// <summary>
/// Verifies that error output redacts the API key even when it was sourced from
/// the <c>--api-key-env</c> environment variable rather than passed via
/// <c>--api-key</c> — the documented default credential path.
/// </summary>
[Fact]
public async Task RunAsync_ErrorOutput_RedactsApiKey_WhenSourcedFromEnvironmentVariable()
{
const string environmentVariableName = "MXGATEWAY_TEST_API_KEY_REDACT";
using var output = new StringWriter();
using var error = new StringWriter();
Environment.SetEnvironmentVariable(environmentVariableName, "env-secret-api-key");
try
{
int exitCode = await MxGatewayClientCli.RunAsync(
[
"open-session",
"--endpoint",
"http://localhost:5000",
"--api-key-env",
environmentVariableName,
],
output,
error,
_ => throw new InvalidOperationException("boom env-secret-api-key"));
Assert.Equal(1, exitCode);
Assert.DoesNotContain("env-secret-api-key", error.ToString());
Assert.Contains("[redacted]", error.ToString());
}
finally
{
Environment.SetEnvironmentVariable(environmentVariableName, null);
}
}
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary> /// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
[Fact] [Fact]
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput() public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
@@ -184,69 +147,6 @@ public sealed class MxGatewayClientCliTests
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString()); Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
} }
/// <summary>
/// Client.Dotnet-017 regression: a finite-window event collector
/// (<c>stream-events --timeout</c>) must exit 0 and emit the events
/// that arrived before the timeout fired, instead of propagating the
/// timeout-driven <see cref="OperationCanceledException"/> as an
/// unhandled exception (exit code -532462766). The fix wraps the
/// <c>await foreach</c> in a token-aware catch so the cancellation
/// ends the foreach gracefully; the aggregated JSON output still runs.
/// </summary>
[Fact]
public async Task RunAsync_StreamEvents_WhenTimeoutFiresAfterEvents_EmitsCollectedEventsAndExitsZero()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.Events.Add(new MxEvent
{
SessionId = "session-fixture",
Family = MxEventFamily.OnDataChange,
WorkerSequence = 1,
});
fakeClient.Events.Add(new MxEvent
{
SessionId = "session-fixture",
Family = MxEventFamily.OnDataChange,
WorkerSequence = 2,
});
// Park forever after yielding the configured events so the CLI's
// --timeout drives the cancellation path.
fakeClient.StreamHangAfterEvents = async token =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, token).ConfigureAwait(false);
};
int exitCode = await MxGatewayClientCli.RunAsync(
[
"stream-events",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--session-id",
"session-fixture",
"--json",
"--max-events",
"200",
"--timeout",
"1s",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
string json = output.ToString();
// Aggregate JSON output must run even though the foreach exited via
// cancellation, and it must contain both events that arrived first.
Assert.Contains("\"events\"", json);
Assert.Contains("\"workerSequence\":\"1\"", json);
Assert.Contains("\"workerSequence\":\"2\"", json);
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary> /// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
[Fact] [Fact]
@@ -549,139 +449,64 @@ public sealed class MxGatewayClientCliTests
Assert.Contains("\"objectCount\": 99", text); Assert.Contains("\"objectCount\": 99", text);
} }
/// <summary>Verifies that batch mode executes a single no-gateway command and writes the EOR sentinel.</summary> /// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
[Fact] [Fact]
public async Task RunAsync_Batch_SingleVersionCommand_WritesOutputAndEorSentinel() public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
{ {
using var output = new StringWriter(); using var output = new StringWriter();
using var error = new StringWriter(); using var error = new StringWriter();
using var stdin = new StringReader("version --json\n"); using var input = new StringReader("version --json\n");
int exitCode = await MxGatewayClientCli.RunAsync( int exitCode = await MxGatewayClientCli.RunAsync(
["batch"], ["batch"],
output, output,
error, error,
clientFactory: null, clientFactory: null,
standardInput: stdin); standardInput: input);
Assert.Equal(0, exitCode); Assert.Equal(0, exitCode);
string text = output.ToString(); string text = output.ToString();
Assert.Contains("\"gatewayProtocolVersion\"", text); Assert.Contains("\"gatewayProtocolVersion\":3", text);
Assert.Contains("__MXGW_BATCH_EOR__", text); Assert.Contains("__MXGW_BATCH_EOR__", text);
// Sentinel must appear after the output, not before. // The EOR marker must come after the JSON output.
int outputIdx = text.IndexOf("gatewayProtocolVersion", StringComparison.Ordinal); int jsonIndex = text.IndexOf("\"gatewayProtocolVersion\"", StringComparison.Ordinal);
int eorIdx = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal); int eorIndex = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
Assert.True(outputIdx < eorIdx, "EOR sentinel must follow command output."); Assert.True(jsonIndex >= 0 && eorIndex > jsonIndex);
Assert.Equal(string.Empty, error.ToString()); Assert.Equal(string.Empty, error.ToString());
} }
/// <summary>Verifies that batch mode processes two commands sequentially and writes two EOR sentinels.</summary> /// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
[Fact] [Fact]
public async Task RunAsync_Batch_TwoVersionCommands_WritesTwoEorSentinels() public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
{ {
using var output = new StringWriter(); using var output = new StringWriter();
using var error = new StringWriter(); using var error = new StringWriter();
// Two commands followed by EOF (end of string). // Unknown command should produce an error on the captured error stream,
using var stdin = new StringReader("version\nversion --json\n"); // which batch mode re-emits to stdout inside the same delimited block.
using var input = new StringReader("nope-not-a-command\nversion\n");
int exitCode = await MxGatewayClientCli.RunAsync( int exitCode = await MxGatewayClientCli.RunAsync(
["batch"], ["batch"],
output, output,
error, error,
clientFactory: null, clientFactory: null,
standardInput: stdin); standardInput: input);
Assert.Equal(0, exitCode); Assert.Equal(0, exitCode);
string text = output.ToString(); string text = output.ToString();
// Two records → two EOR markers.
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal); int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal); int secondEor = text.IndexOf(
Assert.True(firstEor >= 0, "First EOR sentinel must be present."); "__MXGW_BATCH_EOR__",
Assert.True(secondEor > firstEor, "Second EOR sentinel must follow first."); firstEor + 1,
Assert.Equal(string.Empty, error.ToString()); StringComparison.Ordinal);
} Assert.True(firstEor > 0);
Assert.True(secondEor > firstEor);
/// <summary>Verifies that batch mode on EOF (empty stdin) exits 0 immediately without writing any sentinel.</summary> // The unknown-command error message must be on stdout (not on stderr).
[Fact] Assert.Contains("nope-not-a-command", text);
public async Task RunAsync_Batch_EmptyStdin_ExitsZeroWithNoOutput() Assert.DoesNotContain("nope-not-a-command", error.ToString());
{ // The follow-up `version` line should still succeed.
using var output = new StringWriter(); Assert.Contains("gateway-protocol=", text);
using var error = new StringWriter();
using var stdin = new StringReader(string.Empty);
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: null,
standardInput: stdin);
Assert.Equal(0, exitCode);
Assert.Equal(string.Empty, output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>
/// Verifies that batch mode continues after a command failure and writes the error JSON
/// to stdout (not stderr), followed by the EOR sentinel.
/// </summary>
[Fact]
public async Task RunAsync_Batch_CommandFailure_WritesErrorJsonToStdoutAndContinues()
{
using var output = new StringWriter();
using var error = new StringWriter();
// First line: a gateway command with no API key (will fail).
// Second line: version (will succeed).
using var stdin = new StringReader("open-session --endpoint http://localhost:5000\nversion --json\n");
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: _ => throw new InvalidOperationException("injected failure"),
standardInput: stdin);
Assert.Equal(0, exitCode);
string text = output.ToString();
// Error record: the error JSON must be on stdout, not stderr.
Assert.Contains("\"error\"", text);
Assert.Equal(string.Empty, error.ToString());
// Both records must be present.
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
Assert.True(firstEor >= 0, "EOR after failed command must be present.");
Assert.True(secondEor > firstEor, "EOR after successful command must follow first EOR.");
// Second record must contain the version output.
string afterFirstEor = text[(firstEor + "__MXGW_BATCH_EOR__".Length)..];
Assert.Contains("\"gatewayProtocolVersion\"", afterFirstEor);
}
/// <summary>Verifies that batch mode treats an empty (blank) line as EOF and exits 0.</summary>
[Fact]
public async Task RunAsync_Batch_EmptyLine_ExitsZeroAfterPreviousCommands()
{
using var output = new StringWriter();
using var error = new StringWriter();
// One command, then an empty line (stop signal), then another command that must NOT run.
using var stdin = new StringReader("version --json\n\nversion --json\n");
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: null,
standardInput: stdin);
Assert.Equal(0, exitCode);
string text = output.ToString();
// Only one EOR sentinel — the second command after the empty line must not execute.
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
Assert.True(firstEor >= 0, "One EOR sentinel must be present.");
Assert.Equal(-1, secondEor);
Assert.Equal(string.Empty, error.ToString());
} }
/// <summary>Fake CLI client for testing.</summary> /// <summary>Fake CLI client for testing.</summary>
@@ -702,14 +527,6 @@ public sealed class MxGatewayClientCliTests
/// <summary>Exception to throw on invoke, if any.</summary> /// <summary>Exception to throw on invoke, if any.</summary>
public Exception? InvokeFailure { get; init; } public Exception? InvokeFailure { get; init; }
/// <summary>
/// When set, after yielding all <see cref="Events"/> the stream
/// awaits the provided handle and then throws
/// <see cref="OperationCanceledException"/> — used to simulate the
/// CLI timeout / Ctrl+C cancellation path (Client.Dotnet-017).
/// </summary>
public Func<CancellationToken, Task>? StreamHangAfterEvents { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
@@ -769,11 +586,6 @@ public sealed class MxGatewayClientCliTests
await Task.Yield(); await Task.Yield();
yield return gatewayEvent; yield return gatewayEvent;
} }
if (StreamHangAfterEvents is not null)
{
await StreamHangAfterEvents(cancellationToken).ConfigureAwait(false);
}
} }
/// <summary>Queue of acknowledge-alarm replies to return.</summary> /// <summary>Queue of acknowledge-alarm replies to return.</summary>
@@ -1,6 +1,6 @@
using MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts;
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientContractInfoTests public sealed class MxGatewayClientContractInfoTests
{ {
@@ -1,4 +1,4 @@
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientOptionsTests public sealed class MxGatewayClientOptionsTests
{ {
@@ -1,7 +1,7 @@
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Grpc.Core; using Grpc.Core;
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>Tests for MxGatewaySession and client command behavior.</summary> /// <summary>Tests for MxGatewaySession and client command behavior.</summary>
public sealed class MxGatewayClientSessionTests public sealed class MxGatewayClientSessionTests
@@ -184,96 +184,6 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses); Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses);
} }
/// <summary>
/// Verifies that WriteBulk builds one command carrying the entry list verbatim
/// and returns the per-entry BulkWriteResult list without throwing on per-entry
/// failures.
/// </summary>
[Fact]
public async Task WriteBulkAsync_BuildsOneBulkCommandAndReturnsPerEntryResults()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.WriteBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
WriteBulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "Invalid handle" },
},
},
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
IReadOnlyList<BulkWriteResult> results = await session.WriteBulkAsync(
12,
new[]
{
new WriteBulkEntry { ItemHandle = 901, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 } },
new WriteBulkEntry { ItemHandle = 902, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 } },
});
Assert.Equal(2, results.Count);
Assert.True(results[0].WasSuccessful);
Assert.False(results[1].WasSuccessful);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.WriteBulk, request.Command.Kind);
Assert.Equal(12, request.Command.WriteBulk.ServerHandle);
Assert.Equal(2, request.Command.WriteBulk.Entries.Count);
Assert.Equal(901, request.Command.WriteBulk.Entries[0].ItemHandle);
}
/// <summary>
/// Verifies that ReadBulk forwards the timeout to the gateway as milliseconds
/// and unpacks the BulkReadReply payload's was_cached / value fields.
/// </summary>
[Fact]
public async Task ReadBulkAsync_ForwardsTimeoutAndUnpacksCachedFlag()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.ReadBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
ReadBulk = new BulkReadReply
{
Results =
{
new BulkReadResult
{
ServerHandle = 12,
TagAddress = "Area001.Pump001.Speed",
ItemHandle = 901,
WasSuccessful = true,
WasCached = true,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 99 },
},
},
},
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
IReadOnlyList<BulkReadResult> results = await session.ReadBulkAsync(
12,
["Area001.Pump001.Speed"],
TimeSpan.FromMilliseconds(750));
BulkReadResult result = Assert.Single(results);
Assert.True(result.WasCached);
Assert.Equal(99, result.Value.Int32Value);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.ReadBulk, request.Command.Kind);
Assert.Equal(750u, request.Command.ReadBulk.TimeoutMs);
Assert.Equal(["Area001.Pump001.Speed"], request.Command.ReadBulk.TagAddresses);
}
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary> /// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
[Fact] [Fact]
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder() public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
@@ -321,52 +231,6 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal("session-fixture", call.Request.SessionId); Assert.Equal("session-fixture", call.Request.SessionId);
} }
/// <summary>
/// Verifies that disposing a session while other callers are concurrently inside
/// <see cref="MxGatewaySession.CloseAsync"/> — one holding the close lock and one
/// parked on it — never throws <see cref="ObjectDisposedException"/> into those
/// callers. The close lock must outlive every pending close.
/// </summary>
[Fact]
public async Task DisposeAsync_DoesNotRaceConcurrentCloseAsync()
{
for (int iteration = 0; iteration < 100; iteration++)
{
FakeGatewayTransport transport = CreateTransport();
using SemaphoreSlim firstCloseEntered = new(0, 1);
using SemaphoreSlim releaseFirstClose = new(0, 1);
// The first CloseAsync to reach the transport parks here while holding the
// session's close lock; later callers queue on the lock behind it.
transport.CloseSessionHook = async () =>
{
firstCloseEntered.Release();
await releaseFirstClose.WaitAsync().ConfigureAwait(false);
transport.CloseSessionHook = null;
};
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
// Holder enters CloseAsync, acquires the lock, and parks in the hook.
Task holder = Task.Run(() => session.CloseAsync());
await firstCloseEntered.WaitAsync();
// Waiter is parked on the close lock behind the holder.
Task waiter = Task.Run(() => session.CloseAsync());
// DisposeAsync runs concurrently; it must wait out both callers before
// disposing the close lock rather than tearing it down underneath them.
Task dispose = session.DisposeAsync().AsTask();
releaseFirstClose.Release();
await holder;
await waiter;
await dispose;
}
}
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary> /// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
[Fact] [Fact]
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure() public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
@@ -391,35 +255,6 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(2, transport.InvokeCalls.Count); Assert.Equal(2, transport.InvokeCalls.Count);
} }
/// <summary>
/// Verifies that the retry pipeline still retries when the transport maps the raw
/// <see cref="RpcException"/> to an <see cref="MxGatewayException"/> before it reaches
/// the retry predicate — the wrapped-exception shape that production always produces.
/// </summary>
[Fact]
public async Task InvokeAsync_RetriesSafeDiagnosticCommand_WhenTransportMapsRpcException()
{
FakeGatewayTransport transport = CreateTransport();
transport.MapTransportExceptions = true;
transport.InvokeExceptions.Enqueue(CreateTransientRpcException());
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Ping,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
await session.InvokeAsync(new MxCommandRequest
{
SessionId = session.SessionId,
Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() },
});
Assert.Equal(2, transport.InvokeCalls.Count);
}
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary> /// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
[Fact] [Fact]
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure() public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
@@ -468,84 +303,6 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken); Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken);
} }
/// <summary>
/// Verifies that a client-imposed <see cref="StatusCode.DeadlineExceeded"/> is not
/// retried. The deadline budget is shared across the whole safe-unary operation, so
/// an immediate retry would only fail again — the call must surface the failure.
/// </summary>
[Fact]
public async Task InvokeAsync_DoesNotRetrySafeDiagnosticCommand_OnDeadlineExceeded()
{
FakeGatewayTransport transport = CreateTransport();
transport.InvokeExceptions.Enqueue(
new RpcException(new Status(StatusCode.DeadlineExceeded, "deadline exceeded")));
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Ping,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
await Assert.ThrowsAsync<RpcException>(async () => await session.InvokeAsync(
new MxCommandRequest
{
SessionId = session.SessionId,
Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() },
}));
Assert.Single(transport.InvokeCalls);
}
/// <summary>
/// Verifies that a successful register reply missing the typed <c>register</c>
/// payload throws a descriptive <see cref="MxGatewayException"/> rather than
/// silently returning a zero server handle.
/// </summary>
[Fact]
public async Task RegisterAsync_Throws_WhenSuccessfulReplyMissingPayload()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Register,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
async () => await session.RegisterAsync("client-name"));
Assert.Contains("register", exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// Verifies that a successful add-item reply missing the typed <c>add_item</c>
/// payload throws a descriptive <see cref="MxGatewayException"/> rather than
/// silently returning a zero item handle.
/// </summary>
[Fact]
public async Task AddItemAsync_Throws_WhenSuccessfulReplyMissingPayload()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.AddItem,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
async () => await session.AddItemAsync(1, "Area.Pump.Speed"));
Assert.Contains("add_item", exception.Message, StringComparison.Ordinal);
}
private static MxGatewayClient CreateClient(FakeGatewayTransport transport) private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
{ {
return new MxGatewayClient(transport.Options, transport); return new MxGatewayClient(transport.Options, transport);
@@ -1,4 +1,4 @@
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayGeneratedContractTests public sealed class MxGatewayGeneratedContractTests
{ {
@@ -1,9 +1,9 @@
using System.Text.Json; using System.Text.Json;
using Google.Protobuf; using Google.Protobuf;
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxStatusProxyExtensionsTests public sealed class MxStatusProxyExtensionsTests
{ {
@@ -1,9 +1,9 @@
using System.Text.Json; using System.Text.Json;
using Google.Protobuf; using Google.Protobuf;
using MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client.Tests; namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxValueExtensionsTests public sealed class MxValueExtensionsTests
{ {
@@ -19,8 +19,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" /> <ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj" />
<ProjectReference Include="..\MxGateway.Client.Cli\MxGateway.Client.Cli.csproj" /> <ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client.Cli\ZB.MOM.WW.MxGateway.Client.Cli.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,11 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Project Path="../../src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj" />
<Project Path="ZB.MOM.WW.MxGateway.Client.Cli/ZB.MOM.WW.MxGateway.Client.Cli.csproj" />
<Project Path="ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj" />
<Project Path="ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj" />
</Solution>
@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Filters and shape options for <see cref="GalaxyRepositoryClient.DiscoverHierarchyAsync(DiscoverHierarchyOptions, System.Threading.CancellationToken)"/>.
/// </summary>
/// <remarks>
/// Hand-written ergonomic wrapper around the generated
/// <c>DiscoverHierarchyRequest</c>: lets callers express a Galaxy-browse
/// slice with .NET-friendly nullable scalars and collection initializers,
/// without touching the protobuf message's <c>oneof root</c> directly.
/// </remarks>
public sealed class DiscoverHierarchyOptions
{
/// <summary>Restrict to the subtree rooted at this Galaxy <c>gobject_id</c>.</summary>
public int? RootGobjectId { get; init; }
/// <summary>Restrict to the subtree rooted at the object with this tag name.</summary>
public string? RootTagName { get; init; }
/// <summary>Restrict to the subtree rooted at this <c>contained_name</c> path.</summary>
public string? RootContainedPath { get; init; }
/// <summary>Maximum traversal depth, measured from the chosen root.</summary>
public int? MaxDepth { get; init; }
/// <summary>Restrict to objects whose Galaxy category is in this set.</summary>
public IReadOnlyList<int> CategoryIds { get; init; } = [];
/// <summary>Restrict to objects whose template chain contains any of these tokens.</summary>
public IReadOnlyList<string> TemplateChainContains { get; init; } = [];
/// <summary>Optional glob-style filter on <c>tag_name</c>.</summary>
public string? TagNameGlob { get; init; }
/// <summary>Whether to populate each <c>GalaxyObject.Attributes</c>. Null leaves the server default.</summary>
public bool? IncludeAttributes { get; init; }
/// <summary>Restrict to objects that bear at least one alarm attribute.</summary>
public bool AlarmBearingOnly { get; init; }
/// <summary>Restrict to objects that have at least one historized attribute.</summary>
public bool HistorizedOnly { get; init; }
}
@@ -2,14 +2,14 @@ using Google.Protobuf.WellKnownTypes;
using Grpc.Core; using Grpc.Core;
using Grpc.Net.Client; using Grpc.Net.Client;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using Polly; using Polly;
using System.Net.Http; using System.Net.Http;
using System.Net.Security; using System.Net.Security;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary> /// <summary>
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API. /// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
@@ -23,7 +23,7 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
private readonly GrpcChannel? _channel; private readonly GrpcChannel? _channel;
private readonly IGalaxyRepositoryClientTransport _transport; private readonly IGalaxyRepositoryClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline; private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private int _disposed; private bool _disposed;
/// <summary> /// <summary>
/// Initializes a Galaxy Repository client with custom transport and options. /// Initializes a Galaxy Repository client with custom transport and options.
@@ -182,17 +182,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false); return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
} }
/// <summary>
/// Enumerates the deployed Galaxy object hierarchy with caller-supplied
/// server-side filters. Each returned <see cref="GalaxyObject"/> may include
/// its dynamic attributes (controlled by <see cref="DiscoverHierarchyOptions.IncludeAttributes"/>),
/// so callers can determine which tag references they may subscribe to via
/// the MxAccessGateway service. The client transparently follows the
/// gateway's pagination cursor until the hierarchy is fully drained.
/// </summary>
/// <param name="options">Server-side filter and shape options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The filtered collection of Galaxy objects.</returns>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync( public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
DiscoverHierarchyOptions options, DiscoverHierarchyOptions options,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -349,11 +338,12 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
/// </summary> /// </summary>
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
if (Interlocked.Exchange(ref _disposed, 1) != 0) if (_disposed)
{ {
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
_disposed = true;
_channel?.Dispose(); _channel?.Dispose();
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
@@ -454,6 +444,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
private void ThrowIfDisposed() private void ThrowIfDisposed()
{ {
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); ObjectDisposedException.ThrowIf(_disposed, this);
} }
} }
@@ -1,7 +1,7 @@
using Grpc.Core; using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary> /// <summary>
/// gRPC implementation of IGalaxyRepositoryClientTransport. /// gRPC implementation of IGalaxyRepositoryClientTransport.
@@ -36,7 +36,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
} }
catch (RpcException exception) catch (RpcException exception)
{ {
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken); throw MapRpcException(exception, callOptions.CancellationToken);
} }
} }
@@ -53,7 +53,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
} }
catch (RpcException exception) catch (RpcException exception)
{ {
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken); throw MapRpcException(exception, callOptions.CancellationToken);
} }
} }
@@ -70,7 +70,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
} }
catch (RpcException exception) catch (RpcException exception)
{ {
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken); throw MapRpcException(exception, callOptions.CancellationToken);
} }
} }
@@ -101,7 +101,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
} }
catch (RpcException exception) catch (RpcException exception)
{ {
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken); throw MapRpcException(exception, effectiveCancellationToken);
} }
yield return deployEvent; yield return deployEvent;
@@ -115,4 +115,28 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
{ {
return WatchDeployEventsAsync(request, callOptions); return WatchDeployEventsAsync(request, callOptions);
} }
private static Exception MapRpcException(
RpcException exception,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
{
return new OperationCanceledException(
exception.Status.Detail,
exception,
cancellationToken);
}
return exception.StatusCode switch
{
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
exception.Status.Detail,
innerException: exception),
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
exception.Status.Detail,
innerException: exception),
_ => new MxGatewayException(exception.Status.Detail, exception),
};
}
} }
@@ -1,7 +1,7 @@
using Grpc.Core; using Grpc.Core;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary> /// <summary>
/// gRPC implementation of IMxGatewayClientTransport. /// gRPC implementation of IMxGatewayClientTransport.
@@ -36,7 +36,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
catch (RpcException exception) catch (RpcException exception)
{ {
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken); throw MapRpcException(exception, callOptions.CancellationToken);
} }
} }
@@ -53,7 +53,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
catch (RpcException exception) catch (RpcException exception)
{ {
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken); throw MapRpcException(exception, callOptions.CancellationToken);
} }
} }
@@ -70,7 +70,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
catch (RpcException exception) catch (RpcException exception)
{ {
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken); throw MapRpcException(exception, callOptions.CancellationToken);
} }
} }
@@ -101,7 +101,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
catch (RpcException exception) catch (RpcException exception)
{ {
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken); throw MapRpcException(exception, effectiveCancellationToken);
} }
yield return gatewayEvent; yield return gatewayEvent;
@@ -129,10 +129,52 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
catch (RpcException exception) catch (RpcException exception)
{ {
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken); throw MapRpcException(exception, callOptions.CancellationToken);
} }
} }
/// <inheritdoc />
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CallOptions callOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
? cancellationToken
: callOptions.CancellationToken;
using AsyncServerStreamingCall<ActiveAlarmSnapshot> call = RawClient.QueryActiveAlarms(request, callOptions);
IAsyncStreamReader<ActiveAlarmSnapshot> responseStream = call.ResponseStream;
while (true)
{
ActiveAlarmSnapshot? snapshot;
try
{
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
{
break;
}
snapshot = responseStream.Current;
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
}
yield return snapshot;
}
}
/// <inheritdoc />
IAsyncEnumerable<ActiveAlarmSnapshot> IMxGatewayClientTransport.QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CallOptions callOptions)
{
return QueryActiveAlarmsAsync(request, callOptions);
}
/// <inheritdoc /> /// <inheritdoc />
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync( public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request, StreamAlarmsRequest request,
@@ -160,7 +202,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
catch (RpcException exception) catch (RpcException exception)
{ {
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken); throw MapRpcException(exception, effectiveCancellationToken);
} }
yield return message; yield return message;
@@ -174,4 +216,28 @@ internal sealed class GrpcMxGatewayClientTransport(
{ {
return StreamAlarmsAsync(request, callOptions); return StreamAlarmsAsync(request, callOptions);
} }
private static Exception MapRpcException(
RpcException exception,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
{
return new OperationCanceledException(
exception.Status.Detail,
exception,
cancellationToken);
}
return exception.StatusCode switch
{
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
exception.Status.Detail,
innerException: exception),
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
exception.Status.Detail,
innerException: exception),
_ => new MxGatewayException(exception.Status.Detail, exception),
};
}
} }
@@ -1,7 +1,7 @@
using Grpc.Core; using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Transport layer for Galaxy Repository gRPC operations.</summary> /// <summary>Transport layer for Galaxy Repository gRPC operations.</summary>
internal interface IGalaxyRepositoryClientTransport internal interface IGalaxyRepositoryClientTransport
@@ -1,7 +1,7 @@
using Grpc.Core; using Grpc.Core;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
internal interface IMxGatewayClientTransport internal interface IMxGatewayClientTransport
{ {
@@ -65,6 +65,17 @@ internal interface IMxGatewayClientTransport
AcknowledgeAlarmRequest request, AcknowledgeAlarmRequest request,
CallOptions callOptions); CallOptions callOptions);
/// <summary>
/// Streams a snapshot of all alarms currently in Active or ActiveAcked state — the
/// ConditionRefresh equivalent for the gateway.
/// </summary>
/// <param name="request">The query request, optionally scoped by alarm-reference prefix.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>An async enumerable of active-alarm snapshots.</returns>
IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CallOptions callOptions);
/// <summary> /// <summary>
/// Attaches to the gateway's central alarm feed — the current active-alarm /// Attaches to the gateway's central alarm feed — the current active-alarm
/// snapshot followed by live transitions. /// snapshot followed by live transitions.
@@ -1,6 +1,6 @@
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when an MXAccess command fails with a non-zero HResult or failing status.</summary> /// <summary>Exception thrown when an MXAccess command fails with a non-zero HResult or failing status.</summary>
public sealed class MxAccessException : MxGatewayCommandException public sealed class MxAccessException : MxGatewayCommandException
@@ -1,6 +1,6 @@
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Extension methods for checking MxCommandReply success conditions.</summary> /// <summary>Extension methods for checking MxCommandReply success conditions.</summary>
public static class MxCommandReplyExtensions public static class MxCommandReplyExtensions
@@ -1,7 +1,6 @@
using Grpc.Core; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when an API key is invalid, expired, or malformed.</summary> /// <summary>Exception thrown when an API key is invalid, expired, or malformed.</summary>
public sealed class MxGatewayAuthenticationException : MxGatewayException public sealed class MxGatewayAuthenticationException : MxGatewayException
@@ -14,7 +13,6 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
/// <param name="hResult">The HResult code, if available.</param> /// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param> /// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param> /// <param name="innerException">The underlying exception, if any.</param>
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
public MxGatewayAuthenticationException( public MxGatewayAuthenticationException(
string message, string message,
string? sessionId = null, string? sessionId = null,
@@ -22,8 +20,7 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
ProtocolStatus? protocolStatus = null, ProtocolStatus? protocolStatus = null,
int? hResult = null, int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null, IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null, Exception? innerException = null)
StatusCode? statusCode = null)
: base( : base(
message, message,
sessionId, sessionId,
@@ -31,8 +28,7 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
protocolStatus, protocolStatus,
hResult, hResult,
statuses ?? [], statuses ?? [],
innerException, innerException)
statusCode)
{ {
} }
} }
@@ -1,7 +1,6 @@
using Grpc.Core; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when the API key lacks required scopes for an operation.</summary> /// <summary>Exception thrown when the API key lacks required scopes for an operation.</summary>
public sealed class MxGatewayAuthorizationException : MxGatewayException public sealed class MxGatewayAuthorizationException : MxGatewayException
@@ -14,7 +13,6 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
/// <param name="hResult">The HResult code, if available.</param> /// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param> /// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param> /// <param name="innerException">The underlying exception, if any.</param>
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
public MxGatewayAuthorizationException( public MxGatewayAuthorizationException(
string message, string message,
string? sessionId = null, string? sessionId = null,
@@ -22,8 +20,7 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
ProtocolStatus? protocolStatus = null, ProtocolStatus? protocolStatus = null,
int? hResult = null, int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null, IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null, Exception? innerException = null)
StatusCode? statusCode = null)
: base( : base(
message, message,
sessionId, sessionId,
@@ -31,8 +28,7 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
protocolStatus, protocolStatus,
hResult, hResult,
statuses ?? [], statuses ?? [],
innerException, innerException)
statusCode)
{ {
} }
} }
@@ -1,13 +1,13 @@
using Grpc.Core; using Grpc.Core;
using Grpc.Net.Client; using Grpc.Net.Client;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Polly; using Polly;
using System.Net.Http; using System.Net.Http;
using System.Net.Security; using System.Net.Security;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary> /// <summary>
/// Provides the .NET client entry point for the public MXAccess Gateway gRPC API. /// Provides the .NET client entry point for the public MXAccess Gateway gRPC API.
@@ -17,7 +17,7 @@ public sealed class MxGatewayClient : IAsyncDisposable
private readonly GrpcChannel _channel; private readonly GrpcChannel _channel;
private readonly IMxGatewayClientTransport _transport; private readonly IMxGatewayClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline; private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private int _disposed; private bool _disposed;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="MxGatewayClient"/> with given options and transport. /// Initializes a new instance of the <see cref="MxGatewayClient"/> with given options and transport.
@@ -184,10 +184,9 @@ public sealed class MxGatewayClient : IAsyncDisposable
/// <summary> /// <summary>
/// Acknowledges an active MXAccess alarm condition through the gateway. The /// Acknowledges an active MXAccess alarm condition through the gateway. The
/// gateway authorizes <see cref="AcknowledgeAlarmRequest"/> against the API /// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
/// key's <c>admin</c> scope (there is no finer-grained alarm-ack sub-scope) /// scope and forwards the acknowledge to the worker's MXAccess session;
/// and forwards the acknowledge to the worker's MXAccess session; the /// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
/// resulting <see cref="MxStatusProxy"/> is returned in the reply.
/// </summary> /// </summary>
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param> /// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param> /// <param name="cancellationToken">Cancellation token for the operation.</param>
@@ -204,6 +203,27 @@ public sealed class MxGatewayClient : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Streams a snapshot of all alarms currently Active or ActiveAcked — the gateway's
/// ConditionRefresh equivalent. Used after reconnect to seed the local Part 9 state
/// machine, or to reconcile alarms that may have been missed during a transport
/// blip. Optionally scoped by alarm-reference prefix
/// (<see cref="QueryActiveAlarmsRequest.AlarmFilterPrefix"/>) so a partial refresh
/// can target an equipment sub-tree.
/// </summary>
/// <param name="request">The query request, optionally scoped by alarm-reference prefix.</param>
/// <param name="cancellationToken">Cancellation token for the stream.</param>
/// <returns>An async enumerable of active-alarm snapshots.</returns>
public IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return _transport.QueryActiveAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
}
/// <summary> /// <summary>
/// Attaches to the gateway's central alarm feed. The stream opens with one /// Attaches to the gateway's central alarm feed. The stream opens with one
/// <see cref="AlarmFeedMessage"/> per currently-active alarm (the /// <see cref="AlarmFeedMessage"/> per currently-active alarm (the
@@ -231,11 +251,12 @@ public sealed class MxGatewayClient : IAsyncDisposable
/// </summary> /// </summary>
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
if (Interlocked.Exchange(ref _disposed, 1) != 0) if (_disposed)
{ {
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
_disposed = true;
_channel?.Dispose(); _channel?.Dispose();
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
@@ -336,6 +357,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
private void ThrowIfDisposed() private void ThrowIfDisposed()
{ {
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); ObjectDisposedException.ThrowIf(_disposed, this);
} }
} }
@@ -0,0 +1,15 @@
using ZB.MOM.WW.MxGateway.Contracts;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Exposes the protocol versions compiled into this client package.
/// </summary>
public static class MxGatewayClientContractInfo
{
public const uint GatewayProtocolVersion =
GatewayContractInfo.GatewayProtocolVersion;
public const uint WorkerProtocolVersion =
GatewayContractInfo.WorkerProtocolVersion;
}
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary> /// <summary>
/// Configures the gRPC channel used by the .NET MXAccess Gateway client. /// Configures the gRPC channel used by the .NET MXAccess Gateway client.
@@ -38,12 +38,7 @@ public sealed class MxGatewayClientOptions
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10); public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary> /// <summary>
/// Gets the timeout budget for a unary gRPC operation. This is both the gRPC /// Gets the default timeout for unary gRPC calls.
/// deadline stamped on each individual attempt and the overall budget for the
/// whole safe-unary operation: for retryable calls the initial attempt, every
/// retry, and the backoff delays between them all share this single budget.
/// It is therefore an upper bound on the total wall-clock time a safe-unary
/// call can take, not a fresh per-retry allowance.
/// </summary> /// </summary>
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30); public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
@@ -52,11 +47,6 @@ public sealed class MxGatewayClientOptions
/// </summary> /// </summary>
public TimeSpan? StreamTimeout { get; init; } public TimeSpan? StreamTimeout { get; init; }
/// <summary>
/// Gets the maximum size, in bytes, of a single gRPC message the client will
/// send or receive. Applied to both the send and receive limits of the
/// underlying channel. Defaults to 16 MiB.
/// </summary>
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024; public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
/// <summary> /// <summary>
@@ -1,4 +1,4 @@
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Configuration for automatic retry behavior on transient gRPC call failures.</summary> /// <summary>Configuration for automatic retry behavior on transient gRPC call failures.</summary>
public sealed class MxGatewayClientRetryOptions public sealed class MxGatewayClientRetryOptions
@@ -1,10 +1,10 @@
using Grpc.Core; using Grpc.Core;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Polly; using Polly;
using Polly.Retry; using Polly.Retry;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Factory and helpers for exponential-backoff retry policies on transient gRPC failures.</summary> /// <summary>Factory and helpers for exponential-backoff retry policies on transient gRPC failures.</summary>
internal static class MxGatewayClientRetryPolicy internal static class MxGatewayClientRetryPolicy
@@ -61,13 +61,8 @@ internal static class MxGatewayClientRetryPolicy
private static bool IsTransientStatus(StatusCode statusCode) private static bool IsTransientStatus(StatusCode statusCode)
{ {
// DeadlineExceeded is intentionally NOT treated as transient. The deadline
// on every unary call is client-imposed (CreateCallOptions stamps the
// DefaultCallTimeout budget), and that same budget is shared across the
// initial attempt plus all retries plus backoff. A DeadlineExceeded means
// the shared budget is exhausted, so an immediate retry would only fail
// again — burning the remaining budget on a call that cannot succeed.
return statusCode is StatusCode.Unavailable return statusCode is StatusCode.Unavailable
or StatusCode.DeadlineExceeded
or StatusCode.ResourceExhausted; or StatusCode.ResourceExhausted;
} }
} }
@@ -1,6 +1,6 @@
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when a gateway command fails due to an unclassified protocol error.</summary> /// <summary>Exception thrown when a gateway command fails due to an unclassified protocol error.</summary>
public class MxGatewayCommandException : MxGatewayException public class MxGatewayCommandException : MxGatewayException
@@ -1,7 +1,6 @@
using Grpc.Core; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary> /// <summary>
/// Exception thrown when a gateway RPC call fails or returns an error status. /// Exception thrown when a gateway RPC call fails or returns an error status.
@@ -29,20 +28,6 @@ public class MxGatewayException : Exception
Statuses = []; Statuses = [];
} }
/// <summary>
/// Initializes a new instance of the MxGatewayException class carrying the originating
/// gRPC status code so callers can distinguish transient from permanent failures.
/// </summary>
/// <param name="message">Diagnostic message describing the failure.</param>
/// <param name="statusCode">The gRPC status code reported by the failed call.</param>
/// <param name="innerException">Underlying exception that caused this failure.</param>
public MxGatewayException(string message, StatusCode statusCode, Exception? innerException)
: base(message, innerException)
{
StatusCode = statusCode;
Statuses = [];
}
/// <summary> /// <summary>
/// Initializes a new instance of the MxGatewayException class with full diagnostic information. /// Initializes a new instance of the MxGatewayException class with full diagnostic information.
/// </summary> /// </summary>
@@ -53,7 +38,6 @@ public class MxGatewayException : Exception
/// <param name="hResult">HRESULT code returned by the worker or MXAccess, if available.</param> /// <param name="hResult">HRESULT code returned by the worker or MXAccess, if available.</param>
/// <param name="statuses">List of MXAccess status codes returned by the operation.</param> /// <param name="statuses">List of MXAccess status codes returned by the operation.</param>
/// <param name="innerException">Underlying exception that caused this failure.</param> /// <param name="innerException">Underlying exception that caused this failure.</param>
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
public MxGatewayException( public MxGatewayException(
string message, string message,
string? sessionId, string? sessionId,
@@ -61,8 +45,7 @@ public class MxGatewayException : Exception
ProtocolStatus? protocolStatus, ProtocolStatus? protocolStatus,
int? hResult, int? hResult,
IReadOnlyList<MxStatusProxy> statuses, IReadOnlyList<MxStatusProxy> statuses,
Exception? innerException = null, Exception? innerException = null)
StatusCode? statusCode = null)
: base(message, innerException) : base(message, innerException)
{ {
SessionId = sessionId; SessionId = sessionId;
@@ -70,7 +53,6 @@ public class MxGatewayException : Exception
ProtocolStatus = protocolStatus; ProtocolStatus = protocolStatus;
HResultCode = hResult; HResultCode = hResult;
Statuses = statuses; Statuses = statuses;
StatusCode = statusCode;
} }
/// <summary> /// <summary>
@@ -97,15 +79,4 @@ public class MxGatewayException : Exception
/// Gets the list of MXAccess status codes returned by the operation. /// Gets the list of MXAccess status codes returned by the operation.
/// </summary> /// </summary>
public IReadOnlyList<MxStatusProxy> Statuses { get; } public IReadOnlyList<MxStatusProxy> Statuses { get; }
/// <summary>
/// Gets the gRPC status code reported by the failed call, if the failure originated
/// from a gRPC <see cref="RpcException"/>. <see langword="null"/> when the exception
/// was not produced from a gRPC status (for example, a protocol-level reply failure).
/// Callers can inspect this to distinguish a transient outage
/// (<see cref="Grpc.Core.StatusCode.Unavailable"/>) from a permanent error
/// (<see cref="Grpc.Core.StatusCode.InvalidArgument"/>) without downcasting
/// <see cref="Exception.InnerException"/>.
/// </summary>
public StatusCode? StatusCode { get; }
} }
@@ -1,6 +1,6 @@
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary> /// <summary>
/// Represents one gateway-backed MXAccess session. /// Represents one gateway-backed MXAccess session.
@@ -9,10 +9,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
{ {
private readonly MxGatewayClient _client; private readonly MxGatewayClient _client;
private readonly SemaphoreSlim _closeLock = new(1, 1); private readonly SemaphoreSlim _closeLock = new(1, 1);
private readonly object _disposeGate = new();
private CloseSessionReply? _closeReply; private CloseSessionReply? _closeReply;
private int _activeCloseCount;
private bool _closeLockDisposed;
/// <summary> /// <summary>
/// Initializes a new session backed by the given MXAccess gateway client. /// Initializes a new session backed by the given MXAccess gateway client.
@@ -49,17 +46,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
return _closeReply; return _closeReply;
} }
// Register as an in-flight closer under the dispose gate. DisposeAsync waits for
// _activeCloseCount to drain before disposing the close lock, so the semaphore is
// guaranteed to outlive every WaitAsync started here.
lock (_disposeGate)
{
ObjectDisposedException.ThrowIf(_closeLockDisposed, this);
_activeCloseCount++;
}
try
{
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false); await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
@@ -79,14 +65,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
_closeLock.Release(); _closeLock.Release();
} }
} }
finally
{
lock (_disposeGate)
{
_activeCloseCount--;
}
}
}
/// <summary> /// <summary>
/// Registers a client with the MXAccess session, returning a ServerHandle. /// Registers a client with the MXAccess session, returning a ServerHandle.
@@ -101,8 +79,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken) MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Register?.ServerHandle return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value;
?? throw CreateMissingPayloadException(reply, "register");
} }
/// <summary> /// <summary>
@@ -144,8 +121,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItem?.ItemHandle return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
?? throw CreateMissingPayloadException(reply, "add_item");
} }
/// <summary> /// <summary>
@@ -196,8 +172,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItem2?.ItemHandle return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value;
?? throw CreateMissingPayloadException(reply, "add_item2");
} }
/// <summary> /// <summary>
@@ -529,10 +504,14 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <summary> /// <summary>
/// Bulk Write — sequential MXAccess Write per entry on the worker's STA. /// Bulk Write — sequential MXAccess Write per entry on the worker's STA.
/// Per-item failures appear as BulkWriteResult entries with /// Per-item failures appear as <see cref="BulkWriteResult"/> entries with
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors. /// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
/// Protocol-level failures still throw via EnsureProtocolSuccess. /// Protocol-level failures still throw via EnsureProtocolSuccess.
/// </summary> /// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, and user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync( public async Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteBulkEntry> entries, IReadOnlyList<WriteBulkEntry> entries,
@@ -555,7 +534,15 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.WriteBulk?.Results.ToArray() ?? []; return reply.WriteBulk?.Results.ToArray() ?? [];
} }
/// <summary>Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.</summary> /// <summary>
/// Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.
/// Per-item failures appear as <see cref="BulkWriteResult"/> entries with
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, timestamp, and user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync( public async Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<Write2BulkEntry> entries, IReadOnlyList<Write2BulkEntry> entries,
@@ -583,6 +570,10 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// Credential-sensitive values must never reach logs; the client mirrors /// Credential-sensitive values must never reach logs; the client mirrors
/// the single-item WriteSecured redaction contract. /// the single-item WriteSecured redaction contract.
/// </summary> /// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, current user id, and verifier user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync( public async Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries, IReadOnlyList<WriteSecuredBulkEntry> entries,
@@ -605,7 +596,14 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.WriteSecuredBulk?.Results.ToArray() ?? []; return reply.WriteSecuredBulk?.Results.ToArray() ?? [];
} }
/// <summary>Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per entry.</summary> /// <summary>
/// Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per entry.
/// Same redaction rules as <see cref="WriteSecuredBulkAsync"/>.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, timestamp, current user id, and verifier user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync( public async Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries, IReadOnlyList<WriteSecured2BulkEntry> entries,
@@ -631,11 +629,17 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <summary> /// <summary>
/// Bulk Read — snapshot the current value for each requested tag. /// Bulk Read — snapshot the current value for each requested tag.
/// Returns the cached OnDataChange value when the tag is already advised /// Returns the cached OnDataChange value when the tag is already advised
/// (was_cached = true), otherwise the worker takes the full AddItem + /// (<c>WasCached = true</c>), otherwise the worker takes the full AddItem +
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle. Per-tag /// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle. Per-tag
/// failures (timeout, invalid tag) appear as BulkReadResult entries with /// failures (timeout, invalid tag) appear as <see cref="BulkReadResult"/>
/// <c>WasSuccessful = false</c>; the call never throws on per-tag errors. /// entries with <c>WasSuccessful = false</c>; the call never throws on
/// per-tag errors.
/// </summary> /// </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="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkReadResult"/> per requested tag, in request order.</returns>
public async Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync( public async Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
@@ -819,32 +823,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// </summary> /// </summary>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
lock (_disposeGate)
{
if (_closeLockDisposed)
{
return;
}
}
await CloseAsync().ConfigureAwait(false); await CloseAsync().ConfigureAwait(false);
// Wait for every concurrent CloseAsync caller to leave the close lock before
// disposing it; once _closeReply is set those callers return without awaiting.
while (true)
{
lock (_disposeGate)
{
if (_activeCloseCount == 0)
{
_closeLockDisposed = true;
break;
}
}
await Task.Yield();
}
_closeLock.Dispose(); _closeLock.Dispose();
} }
@@ -862,21 +841,4 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Builds the exception thrown when a command reply passed protocol and
/// MXAccess success checks but is missing the typed handle-bearing payload
/// the command contract requires. Surfacing this as a clear error avoids
/// silently handing a zero handle to the caller (it would otherwise fall
/// through to <see cref="MxCommandReply.ReturnValue"/>, which is 0 when the
/// reply carries no return value).
/// </summary>
private static MxGatewayException CreateMissingPayloadException(
MxCommandReply reply,
string expectedPayload)
{
return new MxGatewayException(
$"Gateway reply for command kind={reply.Kind} reported success but is missing "
+ $"the required '{expectedPayload}' payload; cannot resolve a handle. "
+ $"session={reply.SessionId}; correlation={reply.CorrelationId}");
}
} }
@@ -1,6 +1,6 @@
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when a session is not found, not ready, or invalid.</summary> /// <summary>Exception thrown when a session is not found, not ready, or invalid.</summary>
public sealed class MxGatewaySessionException : MxGatewayException public sealed class MxGatewaySessionException : MxGatewayException
@@ -1,6 +1,6 @@
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when the worker process is unavailable or fails to process a command.</summary> /// <summary>Exception thrown when the worker process is unavailable or fails to process a command.</summary>
public sealed class MxGatewayWorkerException : MxGatewayException public sealed class MxGatewayWorkerException : MxGatewayException
@@ -1,6 +1,6 @@
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Extension methods for MxStatusProxy values.</summary> /// <summary>Extension methods for MxStatusProxy values.</summary>
public static class MxStatusProxyExtensions public static class MxStatusProxyExtensions
@@ -1,8 +1,8 @@
using Google.Protobuf; using Google.Protobuf;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client;
/// <summary> /// <summary>
/// Creates and projects gateway MXAccess values without hiding the raw /// Creates and projects gateway MXAccess values without hiding the raw
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ZB.MOM.WW.MxGateway.Client.Tests")]
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj" /> <ProjectReference Include="..\..\..\src\ZB.MOM.WW.MxGateway.Contracts\ZB.MOM.WW.MxGateway.Contracts.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+9 -31
View File
@@ -76,41 +76,20 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
``` ```
`Client.OpenSession` returns a `Session` with helpers for `Register`, `Client.OpenSession` returns a `Session` with helpers for `Register`,
`AddItem`, `AddItem2`, `Advise`, `Write`, the full bulk family `AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
(`AddItemBulk`, `AdviseItemBulk`, `RemoveItemBulk`, `UnAdviseItemBulk`,
`SubscribeBulk`, `UnsubscribeBulk`, `WriteBulk`, `Write2Bulk`,
`WriteSecuredBulk`, `WriteSecured2Bulk`, `ReadBulk`), `Events`, and
`Close`. Bulk variants carry a list of entries in one round-trip and
return one result per entry; per-entry MXAccess failures appear as
`was_successful = false` and never return as Go errors. `ReadBulk` accepts
a `time.Duration` per-tag timeout and returns cached `OnDataChange`
values when the tag is already advised (`WasCached = true`) without
touching the existing subscription. Prefer
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the `SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
returned subscription owns cancellation and exposes `Close` for deterministic returned subscription owns cancellation and exposes `Close` for deterministic
goroutine cleanup. `Events` and `EventsAfter` are a compatibility shim with a goroutine cleanup. Raw protobuf messages remain available through the
bounded internal buffer: if the consumer drains too slowly the buffer fills,
the underlying stream is cancelled, and a terminal `EventResult` carrying
`ErrEventBufferOverflow` is delivered as the channel's last item before it
closes — so a slow consumer can distinguish dropped events from a normal
end-of-stream. `SubscribeEvents` blocks instead of dropping, so use it when no
events may be lost. Raw protobuf messages remain available through the
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support `mxgateway` package aliases and the `Raw` helper methods. Typed errors support
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command `errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
errors preserve the raw reply. errors preserve the raw reply.
`Dial` and `DialGalaxy` create the connection lazily (`grpc.NewClient`): a For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
gateway that is briefly unavailable no longer turns into a hard error — the snapshots, `Client.StreamAlarms` for the server-streaming feed, and
connection recovers once the gateway comes up. To keep fail-fast behavior, `Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
both run a readiness probe bounded by `DialTimeout` (default 10s, or the call returns a `StreamAlarmsClient`; cancel its context to terminate the
context deadline when sooner) and return a `*GatewayError` if the gateway stream. All three pass straight through to the gateway's central alarm
cannot be reached in that window. monitor.
For retry, timeout, and auth handling, `GatewayError.Code()` exposes the
wrapped gRPC `codes.Code`, and `mxgateway.IsTransient(err)` reports whether a
failure (`Unavailable`, `DeadlineExceeded`, `ResourceExhausted`, `Aborted`)
may succeed on retry — so callers do not have to unwrap the error and call
`status.Code` themselves.
## Galaxy Repository browse ## Galaxy Repository browse
@@ -186,8 +165,7 @@ The CLI exposes the same RPC via `galaxy-watch`:
```powershell ```powershell
go run ./cmd/mxgw-go galaxy-watch -plaintext go run ./cmd/mxgw-go galaxy-watch -plaintext
go run ./cmd/mxgw-go galaxy-watch -plaintext -json go run ./cmd/mxgw-go galaxy-watch -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00Z # whole-second RFC 3339 go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00Z
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00.123Z # fractional seconds also accepted
go run ./cmd/mxgw-go galaxy-watch -plaintext -limit 5 go run ./cmd/mxgw-go galaxy-watch -plaintext -limit 5
``` ```
+44 -71
View File
@@ -351,17 +351,17 @@ func runUnsubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Wr
return errors.New("session-id and item-handles are required") return errors.New("session-id and item-handles are required")
} }
handles, err := parseInt32List(*itemHandles)
if err != nil {
return err
}
client, options, err := dialForCommand(ctx, common) client, options, err := dialForCommand(ctx, common)
if err != nil { if err != nil {
return err return err
} }
defer client.Close() defer client.Close()
handles, err := parseInt32List(*itemHandles)
if err != nil {
return err
}
session := mxgateway.NewSessionForID(client, *sessionID) session := mxgateway.NewSessionForID(client, *sessionID)
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), handles) results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), handles)
return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err) return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err)
@@ -396,30 +396,25 @@ func runReadBulk(ctx context.Context, args []string, stdout, stderr io.Writer) e
} }
func runWriteBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error { func runWriteBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-bulk", false) return runWriteBulkVariant(ctx, args, stdout, stderr, "write-bulk", false, false)
} }
func runWrite2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error { func runWrite2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
return runWriteBulkVariant(ctx, args, stdout, stderr, "write2-bulk", true) return runWriteBulkVariant(ctx, args, stdout, stderr, "write2-bulk", true, false)
} }
func runWriteSecuredBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error { func runWriteSecuredBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured-bulk", false) return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured-bulk", false, true)
} }
func runWriteSecured2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error { func runWriteSecured2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured2-bulk", true) return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured2-bulk", true, true)
} }
// runWriteBulkVariant shares the flag-parsing + entry-build skeleton across // runWriteBulkVariant shares the flag-parsing + entry-build skeleton across
// the four bulk-write families. command selects which of the four routes // the four bulk-write families. withTimestamp adds a --timestamp-value flag;
// runs; withTimestamp adds a --timestamp-value flag for the Write2 / Secured2 // secured switches from --user-id to --current-user-id / --verifier-user-id.
// variants. Secured-only flags (--current-user-id / --verifier-user-id) are func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.Writer, command string, withTimestamp bool, secured bool) error {
// only registered for the secured variants and the non-secured -user-id flag
// is only registered for Write/Write2, so a wrong-variant flag becomes a
// clean "flag provided but not defined" error instead of silently no-op'ing.
func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.Writer, command string, withTimestamp bool) error {
secured := command == "write-secured-bulk" || command == "write-secured2-bulk"
flags := flag.NewFlagSet(command, flag.ContinueOnError) flags := flag.NewFlagSet(command, flag.ContinueOnError)
flags.SetOutput(stderr) flags.SetOutput(stderr)
common := bindCommonFlags(flags) common := bindCommonFlags(flags)
@@ -429,13 +424,9 @@ func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.W
itemHandles := flags.String("item-handles", "", "comma-separated item handles") itemHandles := flags.String("item-handles", "", "comma-separated item handles")
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string") valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
values := flags.String("values", "", "comma-separated values (one per item handle)") values := flags.String("values", "", "comma-separated values (one per item handle)")
var userID, currentUserID, verifierUserID *int userID := flags.Int("user-id", 0, "MXAccess user id (Write/Write2 variants)")
if secured { currentUserID := flags.Int("current-user-id", 0, "MXAccess current user id (Secured variants)")
currentUserID = flags.Int("current-user-id", 0, "MXAccess current user id (Secured variants)") verifierUserID := flags.Int("verifier-user-id", 0, "MXAccess verifier user id (Secured variants)")
verifierUserID = flags.Int("verifier-user-id", 0, "MXAccess verifier user id (Secured variants)")
} else {
userID = flags.Int("user-id", 0, "MXAccess user id (Write/Write2 variants)")
}
timestampValue := flags.String("timestamp-value", "", "RFC 3339 timestamp shared across all entries (Write2/WriteSecured2 variants)") timestampValue := flags.String("timestamp-value", "", "RFC 3339 timestamp shared across all entries (Write2/WriteSecured2 variants)")
if err := flags.Parse(args); err != nil { if err := flags.Parse(args); err != nil {
@@ -523,9 +514,20 @@ func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.W
default: default:
return fmt.Errorf("unsupported bulk write command %q", command) return fmt.Errorf("unsupported bulk write command %q", command)
} }
_ = secured // currently only used for routing above; reserved for future per-variant validation
return writeWriteBulkOutput(stdout, *jsonOutput, command, options, results, err) return writeWriteBulkOutput(stdout, *jsonOutput, command, options, results, err)
} }
// parseRfc3339Timestamp parses an RFC 3339 timestamp and returns the
// MxValue protobuf representation used for the timestamped write families.
func parseRfc3339Timestamp(text string) (*mxgateway.MxValue, error) {
t, err := time.Parse(time.RFC3339Nano, text)
if err != nil {
return nil, fmt.Errorf("invalid RFC 3339 timestamp %q: %w", text, err)
}
return mxgateway.TimestampValue(t), nil
}
// runBenchReadBulk drives the cross-language ReadBulk stress benchmark from Go: // runBenchReadBulk drives the cross-language ReadBulk stress benchmark from Go:
// opens its own session, subscribes to bulk-size tags so the worker value cache // opens its own session, subscribes to bulk-size tags so the worker value cache
// populates from real OnDataChange events, runs ReadBulk in a tight loop for // populates from real OnDataChange events, runs ReadBulk in a tight loop for
@@ -596,19 +598,14 @@ func runBenchReadBulk(ctx context.Context, args []string, stdout, stderr io.Writ
}() }()
// Warm-up: drive identical calls so any first-call JIT / connection-pool // Warm-up: drive identical calls so any first-call JIT / connection-pool
// setup is amortised before the measurement window opens. Honor ctx so // setup is amortised before the measurement window opens.
// Ctrl+C or a parent-cancel (e.g. the cross-language bench driver killing
// the child early) exits promptly rather than spinning failing calls until
// the wall-clock deadline.
warmupDeadline := time.Now().Add(time.Duration(*warmupSeconds) * time.Second) warmupDeadline := time.Now().Add(time.Duration(*warmupSeconds) * time.Second)
timeout := time.Duration(*timeoutMs) * time.Millisecond timeout := time.Duration(*timeoutMs) * time.Millisecond
for time.Now().Before(warmupDeadline) && ctx.Err() == nil { for time.Now().Before(warmupDeadline) {
_, _ = session.ReadBulk(ctx, serverHandle, tags, timeout) _, _ = session.ReadBulk(ctx, serverHandle, tags, timeout)
} }
// Steady state: per-call latency captured via time.Now() deltas. Same ctx // Steady state: per-call latency captured via time.Now() deltas.
// guard as warm-up; on cancel we stop the loop and report the truncated
// window faithfully.
latenciesMs := make([]float64, 0, 65536) latenciesMs := make([]float64, 0, 65536)
var totalReadResults int64 var totalReadResults int64
var cachedReadResults int64 var cachedReadResults int64
@@ -616,7 +613,7 @@ func runBenchReadBulk(ctx context.Context, args []string, stdout, stderr io.Writ
steadyStart := time.Now() steadyStart := time.Now()
steadyDeadline := steadyStart.Add(time.Duration(*durationSeconds) * time.Second) steadyDeadline := steadyStart.Add(time.Duration(*durationSeconds) * time.Second)
for time.Now().Before(steadyDeadline) && ctx.Err() == nil { for time.Now().Before(steadyDeadline) {
callStart := time.Now() callStart := time.Now()
results, err := session.ReadBulk(ctx, serverHandle, tags, timeout) results, err := session.ReadBulk(ctx, serverHandle, tags, timeout)
elapsed := time.Since(callStart) elapsed := time.Since(callStart)
@@ -676,7 +673,7 @@ func percentileSummary(sample []float64) map[string]float64 {
sorted := append([]float64(nil), sample...) sorted := append([]float64(nil), sample...)
sort.Float64s(sorted) sort.Float64s(sorted)
mean := 0.0 mean := 0.0
max := sorted[len(sorted)-1] maxValue := sorted[len(sorted)-1]
for _, v := range sample { for _, v := range sample {
mean += v mean += v
} }
@@ -685,7 +682,7 @@ func percentileSummary(sample []float64) map[string]float64 {
"p50": roundTo(percentile(sorted, 0.50), 3), "p50": roundTo(percentile(sorted, 0.50), 3),
"p95": roundTo(percentile(sorted, 0.95), 3), "p95": roundTo(percentile(sorted, 0.95), 3),
"p99": roundTo(percentile(sorted, 0.99), 3), "p99": roundTo(percentile(sorted, 0.99), 3),
"max": roundTo(max, 3), "max": roundTo(maxValue, 3),
"mean": roundTo(mean, 3), "mean": roundTo(mean, 3),
} }
} }
@@ -717,16 +714,6 @@ func roundTo(value float64, digits int) float64 {
return float64(int64(value*shift+0.5)) / shift return float64(int64(value*shift+0.5)) / shift
} }
// parseRfc3339Timestamp parses an RFC 3339 timestamp and returns the
// MxValue protobuf representation used for the timestamped write families.
func parseRfc3339Timestamp(text string) (*mxgateway.MxValue, error) {
t, err := time.Parse(time.RFC3339Nano, text)
if err != nil {
return nil, fmt.Errorf("invalid RFC 3339 timestamp %q: %w", text, err)
}
return mxgateway.TimestampValue(t), nil
}
func runWrite(ctx context.Context, args []string, stdout, stderr io.Writer) error { func runWrite(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("write", flag.ContinueOnError) flags := flag.NewFlagSet("write", flag.ContinueOnError)
flags.SetOutput(stderr) flags.SetOutput(stderr)
@@ -784,15 +771,8 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
} }
defer client.Close() defer client.Close()
// Mirror runGalaxyWatch so Ctrl+C on a long-running stream-events command
// cancels the gRPC stream cleanly (the gateway sees codes.Canceled rather
// than a torn TCP connection) and the deferred subscription.Close() /
// client.Close() actually run.
signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stopSignals()
session := mxgateway.NewSessionForID(client, *sessionID) session := mxgateway.NewSessionForID(client, *sessionID)
streamCtx, cancelStream := context.WithCancel(signalCtx) streamCtx, cancelStream := context.WithCancel(ctx)
defer cancelStream() defer cancelStream()
subscription, err := session.SubscribeEventsAfter(streamCtx, *after) subscription, err := session.SubscribeEventsAfter(streamCtx, *after)
if err != nil { if err != nil {
@@ -1088,31 +1068,31 @@ func parseValue(valueType, valueText string) (*mxgateway.MxValue, error) {
case "bool": case "bool":
value, err := strconv.ParseBool(valueText) value, err := strconv.ParseBool(valueText)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err) return nil, err
} }
return mxgateway.BoolValue(value), nil return mxgateway.BoolValue(value), nil
case "int32": case "int32":
value, err := strconv.ParseInt(valueText, 10, 32) value, err := strconv.ParseInt(valueText, 10, 32)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err) return nil, err
} }
return mxgateway.Int32Value(int32(value)), nil return mxgateway.Int32Value(int32(value)), nil
case "int64": case "int64":
value, err := strconv.ParseInt(valueText, 10, 64) value, err := strconv.ParseInt(valueText, 10, 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err) return nil, err
} }
return mxgateway.Int64Value(value), nil return mxgateway.Int64Value(value), nil
case "float": case "float":
value, err := strconv.ParseFloat(valueText, 32) value, err := strconv.ParseFloat(valueText, 32)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err) return nil, err
} }
return mxgateway.FloatValue(float32(value)), nil return mxgateway.FloatValue(float32(value)), nil
case "double": case "double":
value, err := strconv.ParseFloat(valueText, 64) value, err := strconv.ParseFloat(valueText, 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err) return nil, err
} }
return mxgateway.DoubleValue(value), nil return mxgateway.DoubleValue(value), nil
case "string": case "string":
@@ -1200,6 +1180,10 @@ type protojsonMessage interface {
ProtoReflect() protoreflect.Message ProtoReflect() protoreflect.Message
} }
func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|batch>")
}
// batchEOR is the end-of-result sentinel emitted to stdout after every command // batchEOR is the end-of-result sentinel emitted to stdout after every command
// in batch mode, regardless of success or failure. // in batch mode, regardless of success or failure.
const batchEOR = "__MXGW_BATCH_EOR__" const batchEOR = "__MXGW_BATCH_EOR__"
@@ -1236,10 +1220,6 @@ func runBatch(ctx context.Context, in io.Reader, stdout, stderr io.Writer) error
return scanner.Err() return scanner.Err()
} }
func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|batch>")
}
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) { func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
options, err := common.resolved() options, err := common.resolved()
if err != nil { if err != nil {
@@ -1369,7 +1349,7 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
flags.SetOutput(stderr) flags.SetOutput(stderr)
common := bindCommonFlags(flags) common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output") jsonOutput := flags.Bool("json", false, "write JSON output")
lastSeen := flags.String("last-seen-deploy-time", "", "RFC 3339 timestamp (with optional fractional seconds); when set, suppresses the bootstrap event") lastSeen := flags.String("last-seen-deploy-time", "", "RFC3339 timestamp; when set, suppresses the bootstrap event")
limit := flags.Int("limit", 0, "maximum events to read; 0 means unbounded (Ctrl+C to stop)") limit := flags.Int("limit", 0, "maximum events to read; 0 means unbounded (Ctrl+C to stop)")
if err := flags.Parse(args); err != nil { if err := flags.Parse(args); err != nil {
@@ -1378,11 +1358,7 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
var lastSeenPtr *time.Time var lastSeenPtr *time.Time
if *lastSeen != "" { if *lastSeen != "" {
// Use RFC3339Nano so values copy-pasted from galaxy-watch -json output parsed, err := time.Parse(time.RFC3339, *lastSeen)
// (which formatDeployEvent emits with fractional seconds) round-trip;
// RFC3339Nano also accepts whole-second values, so the layout switch is
// strictly broader than the previous time.RFC3339 parse.
parsed, err := time.Parse(time.RFC3339Nano, *lastSeen)
if err != nil { if err != nil {
return fmt.Errorf("invalid -last-seen-deploy-time: %w", err) return fmt.Errorf("invalid -last-seen-deploy-time: %w", err)
} }
@@ -1425,9 +1401,6 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
count++ count++
if *limit > 0 && count >= *limit { if *limit > 0 && count >= *limit {
cancelStream() cancelStream()
// Allow goroutine to drain.
for range events {
}
return nil return nil
} }
case streamErr, ok := <-errs: case streamErr, ok := <-errs:
+28 -193
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"testing" "testing"
) )
@@ -48,6 +47,34 @@ func TestCommonOptionsRedactsAPIKey(t *testing.T) {
} }
} }
func TestRunBatchEmitsEORAfterVersion(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
in := strings.NewReader("version --json\n")
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "\n"+batchEOR+"\n") && !strings.HasSuffix(out, batchEOR+"\n") {
t.Fatalf("expected EOR marker %q in stdout; got: %q", batchEOR, out)
}
idx := strings.Index(out, batchEOR)
if idx <= 0 {
t.Fatalf("EOR marker not found or appeared before any output: %q", out)
}
payload := out[:idx]
var output versionOutput
if err := json.Unmarshal([]byte(payload), &output); err != nil {
t.Fatalf("parse JSON block before EOR: %v (payload=%q)", err, payload)
}
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
t.Fatalf("protocol versions were not populated: %+v", output)
}
}
func TestParseValueBuildsTypedValue(t *testing.T) { func TestParseValueBuildsTypedValue(t *testing.T) {
value, err := parseValue("int32", "123") value, err := parseValue("int32", "123")
if err != nil { if err != nil {
@@ -57,195 +84,3 @@ func TestParseValueBuildsTypedValue(t *testing.T) {
t.Fatalf("int32 value = %d, want 123", got) t.Fatalf("int32 value = %d, want 123", got)
} }
} }
func TestParseInt32ListParsesValidTokens(t *testing.T) {
items, err := parseInt32List("1, 2 ,3")
if err != nil {
t.Fatalf("parseInt32List() error = %v", err)
}
want := []int32{1, 2, 3}
if len(items) != len(want) {
t.Fatalf("parseInt32List() = %v, want %v", items, want)
}
for i := range want {
if items[i] != want[i] {
t.Fatalf("parseInt32List()[%d] = %d, want %d", i, items[i], want[i])
}
}
}
func TestParseInt32ListReturnsErrorOnMalformedToken(t *testing.T) {
items, err := parseInt32List("1,foo")
if err == nil {
t.Fatalf("parseInt32List() error = nil, want a parse error; items = %v", items)
}
if items != nil {
t.Fatalf("parseInt32List() items = %v, want nil on error", items)
}
if !strings.Contains(err.Error(), "foo") {
t.Fatalf("parseInt32List() error = %q, want it to name the bad token", err.Error())
}
}
// TestParseValueWrapsStrconvErrorWithFlagContext pins Client.Go-017: each
// typed branch of parseValue wraps the bare strconv error with `%w` and names
// the offending flag and value, so the CLI surface is consistent with
// parseInt32List ("invalid item handle %q: %w") and parseRfc3339Timestamp
// ("invalid RFC 3339 timestamp %q: %w").
func TestParseValueWrapsStrconvErrorWithFlagContext(t *testing.T) {
cases := []struct {
valueType string
valueText string
}{
{"bool", "notabool"},
{"int32", "foo"},
{"int64", "foo"},
{"float", "notafloat"},
{"double", "notadouble"},
}
for _, tc := range cases {
t.Run(tc.valueType, func(t *testing.T) {
_, err := parseValue(tc.valueType, tc.valueText)
if err == nil {
t.Fatalf("parseValue(%q, %q) error = nil, want a parse error", tc.valueType, tc.valueText)
}
msg := err.Error()
if !strings.Contains(msg, "-value") {
t.Fatalf("parseValue() error = %q, want it to name the -value flag", msg)
}
if !strings.Contains(msg, tc.valueType) {
t.Fatalf("parseValue() error = %q, want it to name the type %q", msg, tc.valueType)
}
if !strings.Contains(msg, tc.valueText) {
t.Fatalf("parseValue() error = %q, want it to name the bad token %q", msg, tc.valueText)
}
// errors.Unwrap must reach the underlying strconv error so callers
// can still errors.Is/As against strconv.ErrSyntax if they care.
if errors.Unwrap(err) == nil {
t.Fatalf("parseValue() returned unwrapped error %q, want a %%w wrap", msg)
}
})
}
}
// TestRunWriteBulkVariantGatesSecuredFlags pins the Client.Go-015 fix at the
// CLI surface: secured-only flags (-current-user-id, -verifier-user-id) must
// not be registered on the non-secured variants, and -user-id must not be
// registered on the secured variants. The flag package rejects an unknown
// flag with "flag provided but not defined", which a future refactor that
// re-broadens flag registration would silently undo without this test.
func TestRunWriteBulkVariantGatesSecuredFlags(t *testing.T) {
cases := []struct {
name string
command string
flag string
}{
{"write-bulk rejects -current-user-id", "write-bulk", "-current-user-id"},
{"write-bulk rejects -verifier-user-id", "write-bulk", "-verifier-user-id"},
{"write2-bulk rejects -current-user-id", "write2-bulk", "-current-user-id"},
{"write-secured-bulk rejects -user-id", "write-secured-bulk", "-user-id"},
{"write-secured2-bulk rejects -user-id", "write-secured2-bulk", "-user-id"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{
tc.command,
"-plaintext",
"-session-id", "sess",
"-server-handle", "1",
"-item-handles", "1",
"-values", "1",
tc.flag, "1",
}, &stdout, &stderr)
if err == nil {
t.Fatalf("runWithIO(%s %s) error = nil, want flag-not-defined", tc.command, tc.flag)
}
combined := err.Error() + stderr.String()
if !strings.Contains(combined, "flag provided but not defined") {
t.Fatalf("runWithIO(%s %s) error/stderr = %q, want 'flag provided but not defined'", tc.command, tc.flag, combined)
}
})
}
}
// TestRunReadBulkRejectsMissingArgs pins the "session-id and items are
// required" validation in runReadBulk before any network dial happens.
func TestRunReadBulkRejectsMissingArgs(t *testing.T) {
cases := []struct {
name string
args []string
}{
{"no flags", []string{"read-bulk"}},
{"missing items", []string{"read-bulk", "-plaintext", "-session-id", "sess"}},
{"missing session-id", []string{"read-bulk", "-plaintext", "-items", "Tag.Attr"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), tc.args, &stdout, &stderr)
if err == nil {
t.Fatalf("runWithIO(%v) error = nil, want validation error", tc.args)
}
if !strings.Contains(err.Error(), "session-id and items are required") {
t.Fatalf("runWithIO(%v) error = %q, want 'session-id and items are required'", tc.args, err.Error())
}
})
}
}
// TestRunBenchReadBulkRejectsNonPositiveBulkSize pins the bulk-size>=1 check
// at runBenchReadBulk's flag-parsing stage so a future refactor cannot drop
// the positivity guard without breaking this test.
func TestRunBenchReadBulkRejectsNonPositiveBulkSize(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{
"bench-read-bulk",
"-plaintext",
"-bulk-size", "0",
}, &stdout, &stderr)
if err == nil {
t.Fatalf("runWithIO(bench-read-bulk -bulk-size 0) error = nil, want positivity error")
}
if !strings.Contains(err.Error(), "bulk-size must be positive") {
t.Fatalf("runWithIO error = %q, want 'bulk-size must be positive'", err.Error())
}
}
// TestRunBenchReadBulkRejectsNonPositiveDuration pins the duration-seconds>=1
// check at runBenchReadBulk's flag-parsing stage.
func TestRunBenchReadBulkRejectsNonPositiveDuration(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{
"bench-read-bulk",
"-plaintext",
"-duration-seconds", "0",
}, &stdout, &stderr)
if err == nil {
t.Fatalf("runWithIO(bench-read-bulk -duration-seconds 0) error = nil, want positivity error")
}
if !strings.Contains(err.Error(), "duration-seconds must be positive") {
t.Fatalf("runWithIO error = %q, want 'duration-seconds must be positive'", err.Error())
}
}
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the explicit
// "item-handles count ... does not match values count ..." check at the CLI
// surface so the validation error surfaces before any dial happens.
func TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{
"write-bulk",
"-plaintext",
"-session-id", "sess",
"-server-handle", "1",
"-item-handles", "1,2,3",
"-values", "10,20",
}, &stdout, &stderr)
if err == nil {
t.Fatalf("runWithIO(write-bulk mismatched counts) error = nil, want mismatch error")
}
if !strings.Contains(err.Error(), "item-handles count") || !strings.Contains(err.Error(), "values count") {
t.Fatalf("runWithIO error = %q, want 'item-handles count ... values count ...'", err.Error())
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
$protoRoot = Join-Path $repoRoot 'src\MxGateway.Contracts\Protos' $protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos'
$outputRoot = Join-Path $PSScriptRoot 'internal\generated' $outputRoot = Join-Path $PSScriptRoot 'internal\generated'
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated' $modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe' $protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
@@ -902,7 +902,7 @@ const file_galaxy_repository_proto_rawDesc = "" +
"\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" + "\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" +
"\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" + "\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" +
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" + "\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3" "\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3"
var ( var (
file_galaxy_repository_proto_rawDescOnce sync.Once file_galaxy_repository_proto_rawDescOnce sync.Once
File diff suppressed because it is too large Load Diff
@@ -25,6 +25,7 @@ const (
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents" MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm" MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
MxAccessGateway_StreamAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms" MxAccessGateway_StreamAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms"
MxAccessGateway_QueryActiveAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms"
) )
// MxAccessGatewayClient is the client API for MxAccessGateway service. // MxAccessGatewayClient is the client API for MxAccessGateway service.
@@ -44,6 +45,12 @@ type MxAccessGatewayClient interface {
// Served by the gateway's always-on alarm monitor; any number of clients // Served by the gateway's always-on alarm monitor; any number of clients
// fan out from the single monitor without opening a worker session. // fan out from the single monitor without opening a worker session.
StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error) StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error)
// Point-in-time snapshot of the currently-active alarm set served from the
// gateway's always-on alarm monitor cache (session-less). Used after a
// reconnect to seed Part 9 client state, or to reconcile alarms that may
// have been missed during a transport blip. Streamed so callers can
// begin processing without buffering the full set.
QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error)
} }
type mxAccessGatewayClient struct { type mxAccessGatewayClient struct {
@@ -132,6 +139,25 @@ func (c *mxAccessGatewayClient) StreamAlarms(ctx context.Context, in *StreamAlar
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MxAccessGateway_StreamAlarmsClient = grpc.ServerStreamingClient[AlarmFeedMessage] type MxAccessGateway_StreamAlarmsClient = grpc.ServerStreamingClient[AlarmFeedMessage]
func (c *mxAccessGatewayClient) QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[2], MxAccessGateway_QueryActiveAlarms_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[QueryActiveAlarmsRequest, ActiveAlarmSnapshot]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MxAccessGateway_QueryActiveAlarmsClient = grpc.ServerStreamingClient[ActiveAlarmSnapshot]
// MxAccessGatewayServer is the server API for MxAccessGateway service. // MxAccessGatewayServer is the server API for MxAccessGateway service.
// All implementations must embed UnimplementedMxAccessGatewayServer // All implementations must embed UnimplementedMxAccessGatewayServer
// for forward compatibility. // for forward compatibility.
@@ -149,6 +175,12 @@ type MxAccessGatewayServer interface {
// Served by the gateway's always-on alarm monitor; any number of clients // Served by the gateway's always-on alarm monitor; any number of clients
// fan out from the single monitor without opening a worker session. // fan out from the single monitor without opening a worker session.
StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error
// Point-in-time snapshot of the currently-active alarm set served from the
// gateway's always-on alarm monitor cache (session-less). Used after a
// reconnect to seed Part 9 client state, or to reconcile alarms that may
// have been missed during a transport blip. Streamed so callers can
// begin processing without buffering the full set.
QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error
mustEmbedUnimplementedMxAccessGatewayServer() mustEmbedUnimplementedMxAccessGatewayServer()
} }
@@ -177,6 +209,9 @@ func (UnimplementedMxAccessGatewayServer) AcknowledgeAlarm(context.Context, *Ack
func (UnimplementedMxAccessGatewayServer) StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error { func (UnimplementedMxAccessGatewayServer) StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error {
return status.Error(codes.Unimplemented, "method StreamAlarms not implemented") return status.Error(codes.Unimplemented, "method StreamAlarms not implemented")
} }
func (UnimplementedMxAccessGatewayServer) QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error {
return status.Error(codes.Unimplemented, "method QueryActiveAlarms not implemented")
}
func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {} func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {}
func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {} func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {}
@@ -292,6 +327,17 @@ func _MxAccessGateway_StreamAlarms_Handler(srv interface{}, stream grpc.ServerSt
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MxAccessGateway_StreamAlarmsServer = grpc.ServerStreamingServer[AlarmFeedMessage] type MxAccessGateway_StreamAlarmsServer = grpc.ServerStreamingServer[AlarmFeedMessage]
func _MxAccessGateway_QueryActiveAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(QueryActiveAlarmsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(MxAccessGatewayServer).QueryActiveAlarms(m, &grpc.GenericServerStream[QueryActiveAlarmsRequest, ActiveAlarmSnapshot]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MxAccessGateway_QueryActiveAlarmsServer = grpc.ServerStreamingServer[ActiveAlarmSnapshot]
// MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service. // MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -327,6 +373,11 @@ var MxAccessGateway_ServiceDesc = grpc.ServiceDesc{
Handler: _MxAccessGateway_StreamAlarms_Handler, Handler: _MxAccessGateway_StreamAlarms_Handler,
ServerStreams: true, ServerStreams: true,
}, },
{
StreamName: "QueryActiveAlarms",
Handler: _MxAccessGateway_QueryActiveAlarms_Handler,
ServerStreams: true,
},
}, },
Metadata: "mxaccess_gateway.proto", Metadata: "mxaccess_gateway.proto",
} }
@@ -1179,7 +1179,7 @@ const file_mxaccess_worker_proto_rawDesc = "" +
"\x1eWORKER_FAULT_CATEGORY_STA_HUNG\x10\t\x12(\n" + "\x1eWORKER_FAULT_CATEGORY_STA_HUNG\x10\t\x12(\n" +
"$WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW\x10\n" + "$WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW\x10\n" +
"\x12*\n" + "\x12*\n" +
"&WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT\x10\vB\x1c\xaa\x02\x19MxGateway.Contracts.Protob\x06proto3" "&WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT\x10\vB&\xaa\x02#ZB.MOM.WW.MxGateway.Contracts.Protob\x06proto3"
var ( var (
file_mxaccess_worker_proto_rawDescOnce sync.Once file_mxaccess_worker_proto_rawDescOnce sync.Once
+21
View File
@@ -31,6 +31,27 @@ func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequ
return reply, nil return reply, nil
} }
// QueryActiveAlarms streams a snapshot of all alarms currently Active or
// ActiveAcked — the gateway's ConditionRefresh equivalent. Used after reconnect
// to seed local Part 9 state, or to reconcile alarms that may have been missed
// during a transport blip.
//
// The returned stream is owned by the caller; cancel ctx to release it.
// Optional alarm-reference prefix scoping (req.AlarmFilterPrefix) limits the
// stream to a sub-tree.
func (c *Client) QueryActiveAlarms(ctx context.Context, req *QueryActiveAlarmsRequest) (QueryActiveAlarmsClient, error) {
if req == nil {
return nil, errors.New("mxgateway: query active alarms request is required")
}
stream, err := c.raw.QueryActiveAlarms(ctx, req)
if err != nil {
return nil, &GatewayError{Op: "query active alarms", Err: err}
}
return stream, nil
}
// StreamAlarms attaches to the gateway's central alarm feed. The stream opens // StreamAlarms attaches to the gateway's central alarm feed. The stream opens
// with one AlarmFeedMessage per currently-active alarm (the ConditionRefresh // with one AlarmFeedMessage per currently-active alarm (the ConditionRefresh
// snapshot), then a single snapshot-complete sentinel, then a transition for // snapshot), then a single snapshot-complete sentinel, then a transition for
+32 -32
View File
@@ -14,7 +14,8 @@ import (
"google.golang.org/grpc/test/bufconn" "google.golang.org/grpc/test/bufconn"
) )
// Pins the Go SDK surface for the alarm RPCs: AcknowledgeAlarm + StreamAlarms. // PR E.4 — pins the Go SDK surface for the new alarm RPCs:
// AcknowledgeAlarm + QueryActiveAlarms.
func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) { func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
fake := &fakeGatewayWithAlarms{ fake := &fakeGatewayWithAlarms{
@@ -61,6 +62,10 @@ func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
defer cleanup() defer cleanup()
_, err := client.AcknowledgeAlarm(context.Background(), nil) _, err := client.AcknowledgeAlarm(context.Background(), nil)
if err == nil || !errors.Is(err, errors.Unwrap(err)) && err.Error() != "mxgateway: acknowledge alarm request is required" {
// Accept either: the helper returned the literal sentinel, or the
// generic transport error — both prove nil was rejected.
}
if err == nil { if err == nil {
t.Fatalf("AcknowledgeAlarm(nil) returned no error") t.Fatalf("AcknowledgeAlarm(nil) returned no error")
} }
@@ -89,7 +94,7 @@ func TestAcknowledgeAlarmMapsUnauthenticated(t *testing.T) {
} }
} }
func TestStreamAlarmsStreamsSnapshotThenSnapshotComplete(t *testing.T) { func TestQueryActiveAlarmsStreamsSnapshots(t *testing.T) {
fake := &fakeGatewayWithAlarms{ fake := &fakeGatewayWithAlarms{
activeSnapshots: []*pb.ActiveAlarmSnapshot{ activeSnapshots: []*pb.ActiveAlarmSnapshot{
{ {
@@ -107,46 +112,46 @@ func TestStreamAlarmsStreamsSnapshotThenSnapshotComplete(t *testing.T) {
client, cleanup := newBufconnClientWithAlarms(t, fake) client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup() defer cleanup()
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{}) stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
SessionId: "session-1",
})
if err != nil { if err != nil {
t.Fatalf("StreamAlarms() error = %v", err) t.Fatalf("QueryActiveAlarms() error = %v", err)
} }
var received []*pb.AlarmFeedMessage var received []*pb.ActiveAlarmSnapshot
for { for {
msg, err := stream.Recv() snap, err := stream.Recv()
if errors.Is(err, io.EOF) { if errors.Is(err, io.EOF) {
break break
} }
if err != nil { if err != nil {
t.Fatalf("stream.Recv() error = %v", err) t.Fatalf("stream.Recv() error = %v", err)
} }
received = append(received, msg) received = append(received, snap)
} }
if len(received) != 3 { if len(received) != 2 {
t.Fatalf("message count = %d, want 3", len(received)) t.Fatalf("snapshot count = %d, want 2", len(received))
} }
if received[0].GetActiveAlarm().GetAlarmFullReference() != "Tank01.Level.HiHi" { if received[0].GetAlarmFullReference() != "Tank01.Level.HiHi" {
t.Fatalf("message[0] ref = %q", received[0].GetActiveAlarm().GetAlarmFullReference()) t.Fatalf("snapshot[0] ref = %q", received[0].GetAlarmFullReference())
} }
if received[1].GetActiveAlarm().GetCurrentState() != pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED { if received[1].GetCurrentState() != pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED {
t.Fatalf("message[1] state = %v", received[1].GetActiveAlarm().GetCurrentState()) t.Fatalf("snapshot[1] state = %v", received[1].GetCurrentState())
}
if !received[2].GetSnapshotComplete() {
t.Fatalf("final message is not snapshot_complete")
} }
} }
func TestStreamAlarmsPassesFilterPrefix(t *testing.T) { func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
fake := &fakeGatewayWithAlarms{} fake := &fakeGatewayWithAlarms{}
client, cleanup := newBufconnClientWithAlarms(t, fake) client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup() defer cleanup()
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{ stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
SessionId: "session-1",
AlarmFilterPrefix: "Tank01.", AlarmFilterPrefix: "Tank01.",
}) })
if err != nil { if err != nil {
t.Fatalf("StreamAlarms() error = %v", err) t.Fatalf("QueryActiveAlarms() error = %v", err)
} }
for { for {
_, err := stream.Recv() _, err := stream.Recv()
@@ -158,7 +163,7 @@ func TestStreamAlarmsPassesFilterPrefix(t *testing.T) {
} }
} }
if got := fake.streamRequest.GetAlarmFilterPrefix(); got != "Tank01." { if got := fake.queryRequest.GetAlarmFilterPrefix(); got != "Tank01." {
t.Fatalf("captured filter prefix = %q", got) t.Fatalf("captured filter prefix = %q", got)
} }
} }
@@ -171,7 +176,7 @@ type fakeGatewayWithAlarms struct {
acknowledgeError error acknowledgeError error
acknowledgeAuth string acknowledgeAuth string
streamRequest *pb.StreamAlarmsRequest queryRequest *pb.QueryActiveAlarmsRequest
activeSnapshots []*pb.ActiveAlarmSnapshot activeSnapshots []*pb.ActiveAlarmSnapshot
} }
@@ -185,24 +190,21 @@ func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.Ac
return s.acknowledgeReply, nil return s.acknowledgeReply, nil
} }
return &pb.AcknowledgeAlarmReply{ return &pb.AcknowledgeAlarmReply{
CorrelationId: req.GetClientCorrelationId(),
ProtocolStatus: &pb.ProtocolStatus{ ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK, Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
}, },
}, nil }, nil
} }
func (s *fakeGatewayWithAlarms) StreamAlarms(req *pb.StreamAlarmsRequest, stream grpc.ServerStreamingServer[pb.AlarmFeedMessage]) error { func (s *fakeGatewayWithAlarms) QueryActiveAlarms(req *pb.QueryActiveAlarmsRequest, stream grpc.ServerStreamingServer[pb.ActiveAlarmSnapshot]) error {
s.streamRequest = req s.queryRequest = req
for _, snap := range s.activeSnapshots { for _, snap := range s.activeSnapshots {
if err := stream.Send(&pb.AlarmFeedMessage{ if err := stream.Send(snap); err != nil {
Payload: &pb.AlarmFeedMessage_ActiveAlarm{ActiveAlarm: snap},
}); err != nil {
return err return err
} }
} }
return stream.Send(&pb.AlarmFeedMessage{ return nil
Payload: &pb.AlarmFeedMessage_SnapshotComplete{SnapshotComplete: true},
})
} }
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) { func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
@@ -216,10 +218,8 @@ func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Cli
dialer := func(ctx context.Context, _ string) (net.Conn, error) { dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx) return listener.DialContext(ctx)
} }
// grpc.NewClient defaults to the dns scheme; use passthrough so the
// bufconn fake target reaches the context dialer unresolved.
client, err := Dial(context.Background(), Options{ client, err := Dial(context.Background(), Options{
Endpoint: "passthrough:///bufnet", Endpoint: "bufnet",
APIKey: "test-api-key", APIKey: "test-api-key",
Plaintext: true, Plaintext: true,
DialOptions: []grpc.DialOption{grpc.WithContextDialer(dialer)}, DialOptions: []grpc.DialOption{grpc.WithContextDialer(dialer)},
+15 -68
View File
@@ -19,7 +19,6 @@ import (
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/durationpb"
@@ -37,36 +36,22 @@ type Client struct {
opts Options opts Options
} }
// Dial opens a gRPC connection to the gateway and configures auth metadata // Dial opens a gRPC connection to the gateway and configures auth metadata,
// and transport security. // transport security, and blocking dial cancellation from ctx.
//
// The connection is created lazily with grpc.NewClient: the channel is not
// established until the first RPC (or the readiness probe below) needs it, so
// a gateway that is briefly unavailable at Dial time no longer turns into a
// hard error — the connection recovers when the gateway comes up. To preserve
// fail-fast behavior, Dial then runs an explicit readiness probe bounded by
// DialTimeout (default 10s, or ctx's deadline when sooner): it triggers the
// initial connect and waits for the channel to reach Ready, returning a
// *GatewayError if the gateway cannot be reached in that window. Cancelling
// ctx aborts the probe.
func Dial(ctx context.Context, opts Options) (*Client, error) { func Dial(ctx context.Context, opts Options) (*Client, error) {
conn, err := dial(ctx, opts)
if err != nil {
return nil, err
}
return NewClient(conn, opts), nil
}
// dial builds the shared gRPC connection used by both Client and GalaxyClient:
// it resolves transport credentials, assembles dial options, creates a lazy
// connection with grpc.NewClient, and runs the DialTimeout-bounded readiness
// probe so callers still fail fast when the gateway is unreachable.
func dial(ctx context.Context, opts Options) (*grpc.ClientConn, error) {
if opts.Endpoint == "" { if opts.Endpoint == "" {
return nil, errors.New("mxgateway: endpoint is required") return nil, errors.New("mxgateway: endpoint is required")
} }
dialCtx := ctx
cancel := func() {}
if opts.DialTimeout > 0 {
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
} else if _, ok := ctx.Deadline(); !ok {
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
}
defer cancel()
transportCredentials, err := resolveTransportCredentials(opts) transportCredentials, err := resolveTransportCredentials(opts)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -76,46 +61,16 @@ func dial(ctx context.Context, opts Options) (*grpc.ClientConn, error) {
grpc.WithTransportCredentials(transportCredentials), grpc.WithTransportCredentials(transportCredentials),
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)), grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)), grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
grpc.WithBlock(),
} }
dialOptions = append(dialOptions, opts.DialOptions...) dialOptions = append(dialOptions, opts.DialOptions...)
conn, err := grpc.NewClient(opts.Endpoint, dialOptions...) conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
if err != nil { if err != nil {
return nil, &GatewayError{Op: "dial", Err: err} return nil, &GatewayError{Op: "dial", Err: err}
} }
if err := waitForReady(ctx, conn, opts.DialTimeout); err != nil { return NewClient(conn, opts), nil
_ = conn.Close()
return nil, &GatewayError{Op: "dial", Err: err}
}
return conn, nil
}
// waitForReady triggers the initial connect on conn and blocks until the
// channel reaches connectivity.Ready, the timeout elapses, or ctx is
// cancelled. The wait is bounded by dialTimeout when positive, otherwise by
// ctx's existing deadline, otherwise by defaultDialTimeout.
func waitForReady(ctx context.Context, conn *grpc.ClientConn, dialTimeout time.Duration) error {
probeCtx := ctx
cancel := func() {}
if dialTimeout > 0 {
probeCtx, cancel = context.WithTimeout(ctx, dialTimeout)
} else if _, ok := ctx.Deadline(); !ok {
probeCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
}
defer cancel()
conn.Connect()
for {
state := conn.GetState()
if state == connectivity.Ready {
return nil
}
if !conn.WaitForStateChange(probeCtx, state) {
return probeCtx.Err()
}
}
} }
// NewClient wraps an existing gRPC connection. The caller owns closing conn // NewClient wraps an existing gRPC connection. The caller owns closing conn
@@ -233,15 +188,7 @@ func (c *Client) Close() error {
} }
func (c *Client) callContext(ctx context.Context) (context.Context, context.CancelFunc) { func (c *Client) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
return callContext(ctx, c.opts.CallTimeout) timeout := c.opts.CallTimeout
}
// callContext derives a per-RPC context from ctx, applying callTimeout: zero
// uses defaultCallTimeout, a negative value disables the bound entirely, and a
// caller-supplied deadline that is already sooner than the derived timeout is
// kept as-is rather than being lengthened.
func callContext(ctx context.Context, callTimeout time.Duration) (context.Context, context.CancelFunc) {
timeout := callTimeout
if timeout == 0 { if timeout == 0 {
timeout = defaultCallTimeout timeout = defaultCallTimeout
} }
+3 -100
View File
@@ -117,7 +117,7 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
fake := &fakeGatewayServer{ fake := &fakeGatewayServer{
streamStarted: make(chan struct{}), streamStarted: make(chan struct{}),
streamDone: make(chan struct{}), streamDone: make(chan struct{}),
streamEventCount: 256, streamEventCount: 64,
} }
client, cleanup := newBufconnClient(t, fake) client, cleanup := newBufconnClient(t, fake)
defer cleanup() defer cleanup()
@@ -135,25 +135,12 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
t.Fatal("compatibility event stream did not stop after result channel filled") t.Fatal("compatibility event stream did not stop after result channel filled")
} }
// A slow consumer that abandons the buffer must still receive an explicit
// terminal overflow error before the channel closes, so it can tell
// "events dropped" apart from "stream ended normally".
var sawOverflow bool
for { for {
select { select {
case result, ok := <-events: case _, ok := <-events:
if !ok { if !ok {
if !sawOverflow {
t.Fatal("compatibility event channel closed without an ErrEventBufferOverflow result")
}
return return
} }
if result.Err != nil {
if !errors.Is(result.Err, ErrEventBufferOverflow) {
t.Fatalf("terminal result error = %v, want ErrEventBufferOverflow", result.Err)
}
sawOverflow = true
}
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatal("compatibility event channel did not close") t.Fatal("compatibility event channel did not close")
} }
@@ -243,87 +230,6 @@ func TestSubscribeBulkBuildsOneBulkCommandAndReturnsResults(t *testing.T) {
} }
} }
func TestWriteBulkBuildsOneBulkCommandAndReturnsPerEntryResults(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
Payload: &pb.MxCommandReply_WriteBulk{
WriteBulk: &pb.BulkWriteReply{
Results: []*pb.BulkWriteResult{
{ServerHandle: 12, ItemHandle: 901, WasSuccessful: true},
{ServerHandle: 12, ItemHandle: 902, WasSuccessful: false, ErrorMessage: "invalid handle"},
},
},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
results, err := session.WriteBulk(context.Background(), 12, []*pb.WriteBulkEntry{
{ItemHandle: 901, UserId: 5, Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 11}}},
{ItemHandle: 902, UserId: 5, Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 22}}},
})
if err != nil {
t.Fatalf("WriteBulk() error = %v", err)
}
if len(results) != 2 || !results[0].GetWasSuccessful() || results[1].GetWasSuccessful() {
t.Fatalf("results = %#v, want [success, failure]", results)
}
req := fake.invokeRequest
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK {
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
}
if got := req.GetCommand().GetWriteBulk().GetEntries(); len(got) != 2 {
t.Fatalf("entries = %#v, want 2", got)
}
}
func TestReadBulkForwardsTimeoutAndUnpacksCachedFlag(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
Payload: &pb.MxCommandReply_ReadBulk{
ReadBulk: &pb.BulkReadReply{
Results: []*pb.BulkReadResult{
{
ServerHandle: 12,
TagAddress: "Area001.Pump001.Speed",
ItemHandle: 34,
WasSuccessful: true,
WasCached: true,
Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 99}},
},
},
},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
results, err := session.ReadBulk(context.Background(), 12, []string{"Area001.Pump001.Speed"}, 750*time.Millisecond)
if err != nil {
t.Fatalf("ReadBulk() error = %v", err)
}
if len(results) != 1 || !results[0].GetWasCached() || results[0].GetValue().GetInt32Value() != 99 {
t.Fatalf("results = %#v", results)
}
if got := fake.invokeRequest.GetCommand().GetReadBulk().GetTimeoutMs(); got != 750 {
t.Fatalf("timeout_ms = %d, want 750", got)
}
}
func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) { func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) {
hresult := int32(-2147467259) hresult := int32(-2147467259)
fake := &fakeGatewayServer{ fake := &fakeGatewayServer{
@@ -373,11 +279,8 @@ func newBufconnClient(t *testing.T, fake *fakeGatewayServer) (*Client, func()) {
dialer := func(ctx context.Context, _ string) (net.Conn, error) { dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx) return listener.DialContext(ctx)
} }
// grpc.NewClient defaults the target scheme to dns; the bufconn fake name
// is not DNS-resolvable, so use the passthrough scheme to hand the target
// straight to the context dialer.
client, err := Dial(context.Background(), Options{ client, err := Dial(context.Background(), Options{
Endpoint: "passthrough:///bufnet", Endpoint: "bufnet",
APIKey: "test-api-key", APIKey: "test-api-key",
Plaintext: true, Plaintext: true,
DialOptions: []grpc.DialOption{ DialOptions: []grpc.DialOption{
-401
View File
@@ -1,401 +0,0 @@
package mxgateway
import (
"context"
"crypto/tls"
"errors"
"net"
"reflect"
"strings"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
// --- Client.Go-008: resolveTransportCredentials precedence -----------------
// TestResolveTransportCredentialsPrecedence covers every branch of
// resolveTransportCredentials, which previously only had the Plaintext path
// exercised.
func TestResolveTransportCredentialsPrecedence(t *testing.T) {
custom := insecure.NewCredentials()
t.Run("TransportCredentialsWins", func(t *testing.T) {
creds, err := resolveTransportCredentials(Options{
TransportCredentials: custom,
Plaintext: true, // must be ignored
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if creds != custom {
t.Fatal("expected the explicit TransportCredentials to be returned as-is")
}
})
t.Run("Plaintext", func(t *testing.T) {
creds, err := resolveTransportCredentials(Options{Plaintext: true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := creds.Info().SecurityProtocol; got != "insecure" {
t.Fatalf("expected insecure credentials, got security protocol %q", got)
}
})
t.Run("CACertFileMissingErrors", func(t *testing.T) {
_, err := resolveTransportCredentials(Options{CACertFile: "does-not-exist.pem"})
if err == nil {
t.Fatal("expected an error for a missing CA cert file")
}
})
t.Run("TLSConfigWithServerNameOverride", func(t *testing.T) {
creds, err := resolveTransportCredentials(Options{
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13},
ServerNameOverride: "gateway.internal",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := creds.Info().ServerName; got != "gateway.internal" {
t.Fatalf("expected ServerName override to be applied, got %q", got)
}
})
t.Run("DefaultTLSFloor", func(t *testing.T) {
creds, err := resolveTransportCredentials(Options{ServerNameOverride: "host"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := creds.Info().SecurityProtocol; got != "tls" {
t.Fatalf("expected the default TLS credentials, got %q", got)
}
})
}
// TestResolveTransportCredentialsDoesNotMutateTLSConfig confirms the supplied
// TLSConfig is cloned, not mutated, when ServerNameOverride is applied.
func TestResolveTransportCredentialsDoesNotMutateTLSConfig(t *testing.T) {
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
if _, err := resolveTransportCredentials(Options{
TLSConfig: cfg,
ServerNameOverride: "override",
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.ServerName != "" {
t.Fatalf("resolveTransportCredentials mutated the caller's TLSConfig (ServerName=%q)", cfg.ServerName)
}
}
// --- Client.Go-008: callContext deadline arithmetic ------------------------
// TestCallContextDeadlineArithmetic covers the shared callContext deadline
// logic, including the negative-timeout disable case and the
// caller-deadline-is-sooner case.
func TestCallContextDeadlineArithmetic(t *testing.T) {
t.Run("ZeroUsesDefault", func(t *testing.T) {
ctx, cancel := callContext(context.Background(), 0)
defer cancel()
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("expected a deadline for the default timeout")
}
remaining := time.Until(deadline)
if remaining <= 0 || remaining > defaultCallTimeout+time.Second {
t.Fatalf("default deadline out of range: %v", remaining)
}
})
t.Run("NegativeDisablesBound", func(t *testing.T) {
base := context.Background()
ctx, cancel := callContext(base, -1)
defer cancel()
if _, ok := ctx.Deadline(); ok {
t.Fatal("a negative timeout must disable the deadline entirely")
}
if ctx != base {
t.Fatal("a negative timeout must return the caller context unchanged")
}
})
t.Run("PositiveAppliesTimeout", func(t *testing.T) {
ctx, cancel := callContext(context.Background(), 5*time.Second)
defer cancel()
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("expected a deadline")
}
remaining := time.Until(deadline)
if remaining <= 0 || remaining > 5*time.Second+time.Second {
t.Fatalf("deadline out of range: %v", remaining)
}
})
t.Run("CallerDeadlineSoonerIsKept", func(t *testing.T) {
base, baseCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer baseCancel()
ctx, cancel := callContext(base, 30*time.Second)
defer cancel()
if ctx != base {
t.Fatal("a caller deadline sooner than the timeout must be kept as-is")
}
})
t.Run("CallerDeadlineLaterIsShortened", func(t *testing.T) {
base, baseCancel := context.WithTimeout(context.Background(), time.Hour)
defer baseCancel()
ctx, cancel := callContext(base, time.Second)
defer cancel()
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("expected a deadline")
}
if remaining := time.Until(deadline); remaining > 2*time.Second {
t.Fatalf("expected the shorter timeout to win, got %v remaining", remaining)
}
})
}
// --- Client.Go-008: NativeValue / NativeArray edge branches ----------------
// TestNativeValueEdgeKinds covers the array, raw-bytes, null, and
// nil-input branches of NativeValue.
func TestNativeValueEdgeKinds(t *testing.T) {
t.Run("NilInput", func(t *testing.T) {
got, err := NativeValue(nil)
if err != nil || got != nil {
t.Fatalf("NativeValue(nil) = (%v, %v), want (nil, nil)", got, err)
}
})
t.Run("ExplicitNull", func(t *testing.T) {
got, err := NativeValue(&pb.MxValue{IsNull: true})
if err != nil || got != nil {
t.Fatalf("NativeValue(null) = (%v, %v), want (nil, nil)", got, err)
}
})
t.Run("RawBytes", func(t *testing.T) {
raw := []byte{0x01, 0x02, 0x03}
got, err := NativeValue(&pb.MxValue{Kind: &pb.MxValue_RawValue{RawValue: raw}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
gotBytes, ok := got.([]byte)
if !ok || !reflect.DeepEqual(gotBytes, raw) {
t.Fatalf("NativeValue raw = %v, want %v", got, raw)
}
// The result must be a copy, not aliasing the protobuf field.
gotBytes[0] = 0xFF
if raw[0] != 0x01 {
t.Fatal("NativeValue raw result aliases the protobuf backing array")
}
})
t.Run("ArrayValue", func(t *testing.T) {
value := &pb.MxValue{Kind: &pb.MxValue_ArrayValue{
ArrayValue: &pb.MxArray{Values: &pb.MxArray_Int32Values{
Int32Values: &pb.Int32Array{Values: []int32{7, 8}},
}},
}}
got, err := NativeValue(value)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, []int32{7, 8}) {
t.Fatalf("NativeValue array = %v, want [7 8]", got)
}
})
}
// TestNativeArrayEdgeKinds covers the nil, raw-bytes, timestamp-with-nil, and
// unsupported-kind branches of NativeArray.
func TestNativeArrayEdgeKinds(t *testing.T) {
t.Run("NilInput", func(t *testing.T) {
got, err := NativeArray(nil)
if err != nil || got != nil {
t.Fatalf("NativeArray(nil) = (%v, %v), want (nil, nil)", got, err)
}
})
t.Run("RawValues", func(t *testing.T) {
got, err := NativeArray(&pb.MxArray{Values: &pb.MxArray_RawValues{
RawValues: &pb.RawArray{Values: [][]byte{{0x0A}, {0x0B}}},
}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := [][]byte{{0x0A}, {0x0B}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("NativeArray raw = %v, want %v", got, want)
}
})
t.Run("TimestampWithNilEntry", func(t *testing.T) {
got, err := NativeArray(&pb.MxArray{Values: &pb.MxArray_TimestampValues{
TimestampValues: &pb.TimestampArray{Values: []*timestamppb.Timestamp{nil}},
}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
times, ok := got.([]time.Time)
if !ok || len(times) != 1 || !times[0].IsZero() {
t.Fatalf("NativeArray timestamp-with-nil = %v, want [zero-time]", got)
}
})
t.Run("UnsupportedKind", func(t *testing.T) {
// An MxArray with no oneof set hits the default branch.
_, err := NativeArray(&pb.MxArray{})
if err == nil {
t.Fatal("expected an error for an MxArray with no values set")
}
if !strings.Contains(err.Error(), "unsupported array value kind") {
t.Fatalf("unexpected error text: %v", err)
}
})
}
// TestNativeValueUnsupportedKind covers the default branch of NativeValue.
func TestNativeValueUnsupportedKind(t *testing.T) {
// An MxValue with no oneof Kind set and IsNull false hits the default.
_, err := NativeValue(&pb.MxValue{})
if err == nil {
t.Fatal("expected an error for an MxValue with no kind set")
}
if !strings.Contains(err.Error(), "unsupported value kind") {
t.Fatalf("unexpected error text: %v", err)
}
}
// --- Client.Go-005: dial migration -----------------------------------------
// TestDialFailsFastWhenGatewayUnreachable confirms that after the migration to
// grpc.NewClient the DialTimeout-bounded readiness probe still fails fast (and
// wraps the failure in *GatewayError) when the gateway cannot be reached.
func TestDialFailsFastWhenGatewayUnreachable(t *testing.T) {
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return nil, errors.New("connection refused")
}
start := time.Now()
client, err := Dial(context.Background(), Options{
Endpoint: "passthrough:///unreachable",
APIKey: "k",
Plaintext: true,
DialTimeout: 500 * time.Millisecond,
DialOptions: []grpc.DialOption{grpc.WithContextDialer(dialer)},
})
elapsed := time.Since(start)
if err == nil {
client.Close()
t.Fatal("expected Dial to fail for an unreachable gateway")
}
var gwErr *GatewayError
if !errors.As(err, &gwErr) || gwErr.Op != "dial" {
t.Fatalf("expected a *GatewayError with Op=dial, got %#v", err)
}
if elapsed > 5*time.Second {
t.Fatalf("Dial did not honor DialTimeout: took %v", elapsed)
}
}
// TestDialReadinessProbeReachesReady confirms the readiness probe succeeds
// against a live (bufconn) gateway, i.e. the lazy grpc.NewClient connection is
// driven to Ready before Dial returns.
func TestDialReadinessProbeReachesReady(t *testing.T) {
client, cleanup := newBufconnClient(t, &fakeGatewayServer{
openReply: &pb.OpenSessionReply{},
})
defer cleanup()
if client == nil {
t.Fatal("expected a connected client")
}
}
// --- Client.Go-006: error taxonomy ----------------------------------------
// TestGatewayErrorCode confirms GatewayError.Code surfaces the wrapped gRPC
// status code without the caller unwrapping it.
func TestGatewayErrorCode(t *testing.T) {
var nilErr *GatewayError
if got := nilErr.Code(); got != codes.OK {
t.Fatalf("nil GatewayError.Code() = %v, want OK", got)
}
gwErr := &GatewayError{Op: "invoke", Err: status.Error(codes.Unavailable, "down")}
if got := gwErr.Code(); got != codes.Unavailable {
t.Fatalf("GatewayError.Code() = %v, want Unavailable", got)
}
plain := &GatewayError{Op: "dial", Err: errors.New("boom")}
if got := plain.Code(); got != codes.Unknown {
t.Fatalf("GatewayError.Code() for a non-status error = %v, want Unknown", got)
}
}
// TestIsTransient verifies the transient/permanent classification including
// the unwrap-through-GatewayError path.
func TestIsTransient(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "nil", err: nil, want: false},
{name: "unavailable wrapped", err: &GatewayError{Op: "invoke", Err: status.Error(codes.Unavailable, "x")}, want: true},
{name: "deadline wrapped", err: &GatewayError{Op: "invoke", Err: status.Error(codes.DeadlineExceeded, "x")}, want: true},
{name: "resource exhausted", err: &GatewayError{Err: status.Error(codes.ResourceExhausted, "x")}, want: true},
{name: "unauthenticated permanent", err: &GatewayError{Err: status.Error(codes.Unauthenticated, "x")}, want: false},
{name: "invalid argument permanent", err: &GatewayError{Err: status.Error(codes.InvalidArgument, "x")}, want: false},
{name: "bare status unavailable", err: status.Error(codes.Unavailable, "x"), want: true},
{name: "plain error", err: errors.New("nope"), want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsTransient(tt.err); got != tt.want {
t.Fatalf("IsTransient(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// --- Client.Go-007: correlation id fallback --------------------------------
// TestNewCorrelationIDUsesRandEntropy confirms the happy path yields a
// 32-hex-character id.
func TestNewCorrelationIDUsesRandEntropy(t *testing.T) {
id := newCorrelationID()
if len(id) != 32 {
t.Fatalf("expected a 32-char hex id, got %q (len %d)", id, len(id))
}
}
// TestNewCorrelationIDFallsBackOnRandFailure reproduces Client.Go-007: when
// crypto/rand fails, newCorrelationID must not return an empty string but a
// unique, non-empty fallback id so the command stays traceable.
func TestNewCorrelationIDFallsBackOnRandFailure(t *testing.T) {
original := randRead
randRead = func([]byte) (int, error) { return 0, errors.New("entropy unavailable") }
defer func() { randRead = original }()
first := newCorrelationID()
second := newCorrelationID()
if first == "" || second == "" {
t.Fatal("newCorrelationID returned an empty id on rand failure")
}
if !strings.HasPrefix(first, "fallback-") {
t.Fatalf("expected a fallback- prefixed id, got %q", first)
}
if first == second {
t.Fatalf("fallback correlation ids must be unique, got %q twice", first)
}
}
+1 -55
View File
@@ -1,22 +1,11 @@
package mxgateway package mxgateway
import ( import (
"errors"
"fmt" "fmt"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
) )
// ErrEventBufferOverflow is the terminal error delivered on the compatibility
// event channel returned by Session.Events / Session.EventsAfter when a slow
// consumer lets the bounded result buffer fill. It signals that the stream was
// cancelled and events were dropped, so a consumer can tell an overflow apart
// from a normal end-of-stream. Use Session.SubscribeEvents to block instead of
// dropping.
var ErrEventBufferOverflow = errors.New("mxgateway: event buffer overflow; compatibility stream cancelled and events dropped")
// GatewayError wraps transport-level gRPC failures. // GatewayError wraps transport-level gRPC failures.
type GatewayError struct { type GatewayError struct {
// Op names the operation that failed (for example "dial" or "invoke"). // Op names the operation that failed (for example "dial" or "invoke").
@@ -44,45 +33,6 @@ func (e *GatewayError) Unwrap() error {
return e.Err return e.Err
} }
// Code returns the gRPC status code of the wrapped transport error. It returns
// codes.OK when the error is nil and codes.Unknown when the wrapped error does
// not carry a gRPC status. Callers can use it to write retry, timeout, and
// auth handling without manually unwrapping and re-parsing the error.
func (e *GatewayError) Code() codes.Code {
if e == nil || e.Err == nil {
return codes.OK
}
return status.Code(e.Err)
}
// IsTransient reports whether err is a transport failure that may succeed on
// retry — for example a gateway that is briefly Unavailable or a call that
// hit a DeadlineExceeded. Permanent failures (Unauthenticated, PermissionDenied,
// InvalidArgument, NotFound, and similar) return false. It unwraps through
// *GatewayError and any other error chain carrying a gRPC status, so callers
// do not need to call status.Code themselves.
func IsTransient(err error) bool {
if err == nil {
return false
}
switch transientCode(err) {
case codes.Unavailable, codes.DeadlineExceeded, codes.ResourceExhausted, codes.Aborted:
return true
default:
return false
}
}
// transientCode extracts a gRPC status code from err, preferring a wrapped
// *GatewayError's Code and otherwise falling back to status.Code on the chain.
func transientCode(err error) codes.Code {
var gatewayErr *GatewayError
if errors.As(err, &gatewayErr) {
return gatewayErr.Code()
}
return status.Code(err)
}
// CommandError reports a non-OK gateway protocol status and keeps the raw // CommandError reports a non-OK gateway protocol status and keeps the raw
// command reply when one exists. // command reply when one exists.
type CommandError struct { type CommandError struct {
@@ -135,12 +85,8 @@ func (e *MxAccessError) Error() string {
} }
// Unwrap returns the wrapped CommandError, when one is present. // Unwrap returns the wrapped CommandError, when one is present.
//
// When Command is nil (the HRESULT / MxStatusProxy path) it returns an
// untyped nil rather than a typed-nil *CommandError, so errors.As does not
// bind a nil pointer that a caller would then panic on.
func (e *MxAccessError) Unwrap() error { func (e *MxAccessError) Unwrap() error {
if e == nil || e.Command == nil { if e == nil {
return nil return nil
} }
return e.Command return e.Command
-42
View File
@@ -1,42 +0,0 @@
package mxgateway
import (
"errors"
"testing"
)
// TestMxAccessErrorUnwrapHResultPathNoTypedNilCommandError reproduces
// Client.Go-001: an MxAccessError built via the HRESULT / MxStatusProxy path
// leaves Command nil. Unwrap must not hand back a typed-nil *CommandError,
// because errors.As would then succeed while binding a nil pointer and a
// caller dereferencing it would panic.
func TestMxAccessErrorUnwrapHResultPathNoTypedNilCommandError(t *testing.T) {
hresult := int32(-2147467259) // 0x80004005, a failing HRESULT.
reply := &MxCommandReply{Hresult: &hresult}
err := EnsureMxAccessSuccess("invoke", reply)
if err == nil {
t.Fatal("expected MxAccessError for a failing HRESULT, got nil")
}
var ce *CommandError
if errors.As(err, &ce) {
t.Fatalf("errors.As bound *CommandError from an HRESULT-only MxAccessError (ce=%v); "+
"a caller dereferencing ce.Status would panic", ce)
}
}
// TestMxAccessErrorUnwrapPopulatedCommand confirms the non-nil Command path
// still unwraps to the wrapped *CommandError.
func TestMxAccessErrorUnwrapPopulatedCommand(t *testing.T) {
command := &CommandError{Op: "invoke"}
err := &MxAccessError{Command: command}
var ce *CommandError
if !errors.As(err, &ce) {
t.Fatal("errors.As failed to bind the populated *CommandError")
}
if ce != command {
t.Fatalf("errors.As bound an unexpected *CommandError: got %v want %v", ce, command)
}
}
+43 -4
View File
@@ -56,13 +56,39 @@ type GalaxyClient struct {
// DialGalaxy opens a gRPC connection to the gateway for the Galaxy Repository // DialGalaxy opens a gRPC connection to the gateway for the Galaxy Repository
// service. It applies the same authentication metadata, transport security, // service. It applies the same authentication metadata, transport security,
// lazy connection, and DialTimeout-bounded readiness probe as Dial. // and dial-timeout behavior as Dial.
func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) { func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) {
conn, err := dial(ctx, opts) if opts.Endpoint == "" {
return nil, errors.New("mxgateway: endpoint is required")
}
dialCtx := ctx
cancel := func() {}
if opts.DialTimeout > 0 {
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
} else if _, ok := ctx.Deadline(); !ok {
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
}
defer cancel()
transportCredentials, err := resolveTransportCredentials(opts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
dialOptions := []grpc.DialOption{
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...)
if err != nil {
return nil, &GatewayError{Op: "dial", Err: err}
}
return NewGalaxyClient(conn, opts), nil return NewGalaxyClient(conn, opts), nil
} }
@@ -187,7 +213,7 @@ func (c *GalaxyClient) WatchDeployEvents(
} }
continue continue
} }
if errors.Is(recvErr, io.EOF) { if recvErr == io.EOF {
return return
} }
if status.Code(recvErr) == codes.Canceled || ctx.Err() != nil { if status.Code(recvErr) == codes.Canceled || ctx.Err() != nil {
@@ -213,5 +239,18 @@ func (c *GalaxyClient) Close() error {
} }
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
return callContext(ctx, c.opts.CallTimeout) timeout := c.opts.CallTimeout
if timeout == 0 {
timeout = defaultCallTimeout
}
if timeout < 0 {
return ctx, func() {}
}
if deadline, ok := ctx.Deadline(); ok {
timeoutDeadline := time.Now().Add(timeout)
if deadline.Before(timeoutDeadline) {
return ctx, func() {}
}
}
return context.WithTimeout(ctx, timeout)
} }
+9 -3
View File
@@ -348,10 +348,8 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
dialer := func(ctx context.Context, _ string) (net.Conn, error) { dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx) return listener.DialContext(ctx)
} }
// grpc.NewClient defaults to the dns scheme; use passthrough so the
// bufconn fake target reaches the context dialer unresolved.
client, err := DialGalaxy(context.Background(), Options{ client, err := DialGalaxy(context.Background(), Options{
Endpoint: "passthrough:///bufnet", Endpoint: "bufnet",
APIKey: "test-api-key", APIKey: "test-api-key",
Plaintext: true, Plaintext: true,
DialOptions: []grpc.DialOption{ DialOptions: []grpc.DialOption{
@@ -379,6 +377,7 @@ type fakeGalaxyServer struct {
discoverReply *pb.DiscoverHierarchyReply discoverReply *pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest watchRequest *pb.WatchDeployEventsRequest
watchSendInterval time.Duration
watchHoldOpen bool watchHoldOpen bool
} }
@@ -413,6 +412,13 @@ func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, s
if err := stream.Send(event); err != nil { if err := stream.Send(event); err != nil {
return err return err
} }
if s.watchSendInterval > 0 {
select {
case <-time.After(s.watchSendInterval):
case <-stream.Context().Done():
return stream.Context().Err()
}
}
} }
if s.watchHoldOpen { if s.watchHoldOpen {
<-stream.Context().Done() <-stream.Context().Done()
+4 -44
View File
@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"io" "io"
"sync" "sync"
"sync/atomic"
"time" "time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
@@ -599,7 +598,7 @@ func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence
} }
continue continue
} }
if errors.Is(err, io.EOF) || status.Code(err) == codes.Canceled || streamCtx.Err() != nil { if err == io.EOF || status.Code(err) == codes.Canceled || streamCtx.Err() != nil {
return return
} }
sendEventResult( sendEventResult(
@@ -628,7 +627,7 @@ func ensureBulkSize(name string, length int) error {
func sendEventResult( func sendEventResult(
ctx context.Context, ctx context.Context,
results chan EventResult, results chan<- EventResult,
result EventResult, result EventResult,
cancelWhenBufferFull bool, cancelWhenBufferFull bool,
cancel context.CancelFunc, cancel context.CancelFunc,
@@ -640,12 +639,7 @@ func sendEventResult(
case <-ctx.Done(): case <-ctx.Done():
return false return false
default: default:
// The bounded compatibility buffer is full. Cancel the stream and
// deliver an explicit terminal overflow error so a slow consumer
// can tell dropped events apart from a normal end-of-stream,
// rather than seeing the channel close silently.
cancel() cancel()
deliverTerminalResult(results, EventResult{Err: ErrEventBufferOverflow})
return false return false
} }
} }
@@ -658,25 +652,6 @@ func sendEventResult(
} }
} }
// deliverTerminalResult places result on a full buffered channel by evicting
// one of the oldest buffered events to make room. The caller closes results
// afterwards, so the terminal result becomes the consumer's last item.
func deliverTerminalResult(results chan EventResult, result EventResult) {
for {
select {
case results <- result:
return
default:
}
select {
case <-results:
default:
// Another receiver drained the channel between the send and
// receive attempts; retry the send.
}
}
}
func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCommandReply, error) { func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCommandReply, error) {
return s.client.Invoke(ctx, &pb.MxCommandRequest{ return s.client.Invoke(ctx, &pb.MxCommandRequest{
SessionId: s.ID(), SessionId: s.ID(),
@@ -685,25 +660,10 @@ func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCom
}) })
} }
// correlationIDCounter backs the deterministic fallback id used when
// crypto/rand is unavailable, so every command still carries a unique,
// traceable correlation id.
var correlationIDCounter atomic.Uint64
// randRead is the entropy source for newCorrelationID. It is a package
// variable solely so tests can simulate a crypto/rand failure.
var randRead = rand.Read
// newCorrelationID returns a unique correlation id for an MxCommandRequest.
// It prefers 16 bytes of crypto/rand entropy; if rand.Read fails (rare) it
// falls back to a "fallback-" prefixed id built from the current time and a
// process-wide monotonic counter rather than returning an empty string, which
// would leave the command untraceable in gateway logs.
func newCorrelationID() string { func newCorrelationID() string {
var buffer [16]byte var buffer [16]byte
if _, err := randRead(buffer[:]); err != nil { if _, err := rand.Read(buffer[:]); err != nil {
return fmt.Sprintf("fallback-%x-%x", return ""
time.Now().UnixNano(), correlationIDCounter.Add(1))
} }
return hex.EncodeToString(buffer[:]) return hex.EncodeToString(buffer[:])
} }
+22 -26
View File
@@ -70,32 +70,32 @@ type (
WriteCommand = pb.WriteCommand WriteCommand = pb.WriteCommand
// Write2Command is the payload of an MXAccess Write2 command. // Write2Command is the payload of an MXAccess Write2 command.
Write2Command = pb.Write2Command Write2Command = pb.Write2Command
// WriteBulkCommand carries one bulk-Write request. // WriteBulkCommand is the payload of a bulk Write command.
WriteBulkCommand = pb.WriteBulkCommand WriteBulkCommand = pb.WriteBulkCommand
// WriteBulkEntry is one (item_handle, value, user_id) tuple in a WriteBulk request. // WriteBulkEntry is one entry inside a WriteBulkCommand.
WriteBulkEntry = pb.WriteBulkEntry WriteBulkEntry = pb.WriteBulkEntry
// Write2BulkCommand carries one bulk-Write2 (timestamped) request. // Write2BulkCommand is the payload of a bulk Write2 (timestamped) command.
Write2BulkCommand = pb.Write2BulkCommand Write2BulkCommand = pb.Write2BulkCommand
// Write2BulkEntry is one (item_handle, value, timestamp_value, user_id) tuple in a Write2Bulk request. // Write2BulkEntry is one entry inside a Write2BulkCommand.
Write2BulkEntry = pb.Write2BulkEntry Write2BulkEntry = pb.Write2BulkEntry
// WriteSecuredBulkCommand carries one bulk-WriteSecured request. Values are credential-sensitive. // WriteSecuredBulkCommand is the payload of a bulk WriteSecured command.
WriteSecuredBulkCommand = pb.WriteSecuredBulkCommand WriteSecuredBulkCommand = pb.WriteSecuredBulkCommand
// WriteSecuredBulkEntry is one entry in a WriteSecuredBulk request. // WriteSecuredBulkEntry is one entry inside a WriteSecuredBulkCommand.
WriteSecuredBulkEntry = pb.WriteSecuredBulkEntry WriteSecuredBulkEntry = pb.WriteSecuredBulkEntry
// WriteSecured2BulkCommand carries one bulk-WriteSecured2 (timestamped) request. // WriteSecured2BulkCommand is the payload of a bulk WriteSecured2 (timestamped) command.
WriteSecured2BulkCommand = pb.WriteSecured2BulkCommand WriteSecured2BulkCommand = pb.WriteSecured2BulkCommand
// WriteSecured2BulkEntry is one entry in a WriteSecured2Bulk request. // WriteSecured2BulkEntry is one entry inside a WriteSecured2BulkCommand.
WriteSecured2BulkEntry = pb.WriteSecured2BulkEntry WriteSecured2BulkEntry = pb.WriteSecured2BulkEntry
// ReadBulkCommand carries one bulk-Read request. // ReadBulkCommand is the payload of a bulk Read snapshot command.
ReadBulkCommand = pb.ReadBulkCommand ReadBulkCommand = pb.ReadBulkCommand
// BulkWriteResult is one per-entry result in a bulk-write reply. // BulkWriteReply aggregates BulkWriteResult entries for a bulk write command.
BulkWriteResult = pb.BulkWriteResult
// BulkWriteReply aggregates BulkWriteResult entries for a bulk-write command.
BulkWriteReply = pb.BulkWriteReply BulkWriteReply = pb.BulkWriteReply
// BulkReadResult is one per-tag result in a bulk-read reply (carries the snapshot value plus a was_cached flag). // BulkWriteResult is one entry in a bulk write reply list.
BulkReadResult = pb.BulkReadResult BulkWriteResult = pb.BulkWriteResult
// BulkReadReply aggregates BulkReadResult entries for a ReadBulk command. // BulkReadReply aggregates BulkReadResult entries for a bulk read command.
BulkReadReply = pb.BulkReadReply BulkReadReply = pb.BulkReadReply
// BulkReadResult is one entry in a bulk read reply list.
BulkReadResult = pb.BulkReadResult
// RegisterReply carries the ServerHandle returned by Register. // RegisterReply carries the ServerHandle returned by Register.
RegisterReply = pb.RegisterReply RegisterReply = pb.RegisterReply
// AddItemReply carries the ItemHandle returned by AddItem. // AddItemReply carries the ItemHandle returned by AddItem.
@@ -110,12 +110,14 @@ type (
AcknowledgeAlarmRequest = pb.AcknowledgeAlarmRequest AcknowledgeAlarmRequest = pb.AcknowledgeAlarmRequest
// AcknowledgeAlarmReply is the gateway AcknowledgeAlarm reply message. // AcknowledgeAlarmReply is the gateway AcknowledgeAlarm reply message.
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
// QueryActiveAlarmsRequest is the gateway QueryActiveAlarms request message.
QueryActiveAlarmsRequest = pb.QueryActiveAlarmsRequest
// StreamAlarmsRequest is the gateway StreamAlarms request message. // StreamAlarmsRequest is the gateway StreamAlarms request message.
StreamAlarmsRequest = pb.StreamAlarmsRequest StreamAlarmsRequest = pb.StreamAlarmsRequest
// AlarmFeedMessage is one message on the StreamAlarms feed — an // AlarmFeedMessage is one message on the StreamAlarms feed — an
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition. // active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
AlarmFeedMessage = pb.AlarmFeedMessage AlarmFeedMessage = pb.AlarmFeedMessage
// ActiveAlarmSnapshot is one currently-active alarm in the feed snapshot. // ActiveAlarmSnapshot is one row in a ConditionRefresh stream.
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents. // OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
OnAlarmTransitionEvent = pb.OnAlarmTransitionEvent OnAlarmTransitionEvent = pb.OnAlarmTransitionEvent
@@ -129,6 +131,10 @@ type AlarmTransitionKind = pb.AlarmTransitionKind
// ConditionRefresh snapshot. // ConditionRefresh snapshot.
type AlarmConditionState = pb.AlarmConditionState type AlarmConditionState = pb.AlarmConditionState
// QueryActiveAlarmsClient is the generated server-streaming client for the
// QueryActiveAlarms RPC.
type QueryActiveAlarmsClient = pb.MxAccessGateway_QueryActiveAlarmsClient
// StreamAlarmsClient is the generated server-streaming client for the // StreamAlarmsClient is the generated server-streaming client for the
// StreamAlarms RPC. // StreamAlarms RPC.
type StreamAlarmsClient = pb.MxAccessGateway_StreamAlarmsClient type StreamAlarmsClient = pb.MxAccessGateway_StreamAlarmsClient
@@ -184,16 +190,6 @@ const (
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
// CommandKindWrite2 selects the MXAccess Write2 command. // CommandKindWrite2 selects the MXAccess Write2 command.
CommandKindWrite2 = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2 CommandKindWrite2 = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2
// CommandKindWriteBulk selects the bulk Write command.
CommandKindWriteBulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK
// CommandKindWrite2Bulk selects the bulk Write2 (timestamped) command.
CommandKindWrite2Bulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK
// CommandKindWriteSecuredBulk selects the bulk WriteSecured command.
CommandKindWriteSecuredBulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK
// CommandKindWriteSecured2Bulk selects the bulk WriteSecured2 (timestamped) command.
CommandKindWriteSecured2Bulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK
// CommandKindReadBulk selects the bulk Read command (cached-or-snapshot per tag).
CommandKindReadBulk = pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK
// DataTypeUnknown denotes an unrecognized MXAccess data type. // DataTypeUnknown denotes an unrecognized MXAccess data type.
DataTypeUnknown = pb.MxDataType_MX_DATA_TYPE_UNKNOWN DataTypeUnknown = pb.MxDataType_MX_DATA_TYPE_UNKNOWN
+11 -11
View File
@@ -18,13 +18,13 @@ clients/java/
settings.gradle settings.gradle
build.gradle build.gradle
src/main/generated/ src/main/generated/
mxgateway-client/ zb-mom-ww-mxgateway-client/
build.gradle build.gradle
src/main/java/com/dohertylan/mxgateway/client/ src/main/java/com/zb/mom/ww/mxgateway/client/
src/test/java/com/dohertylan/mxgateway/client/ src/test/java/com/zb/mom/ww/mxgateway/client/
mxgateway-cli/ zb-mom-ww-mxgateway-cli/
build.gradle build.gradle
src/main/java/com/dohertylan/mxgateway/cli/ src/main/java/com/zb/mom/ww/mxgateway/cli/
``` ```
Alternative Maven layout is acceptable if the repo standardizes on Maven. Alternative Maven layout is acceptable if the repo standardizes on Maven.
@@ -192,8 +192,8 @@ stream for bounded time, and close.
Publish library and CLI separately: Publish library and CLI separately:
- `mxgateway-client` jar, - `zb-mom-ww-mxgateway-client` jar,
- `mxgateway-cli` runnable distribution. - `zb-mom-ww-mxgateway-cli` runnable distribution.
Generated protobuf code should be produced during the build from shared proto Generated protobuf code should be produced during the build from shared proto
files and should not be hand-edited. files and should not be hand-edited.
@@ -206,10 +206,10 @@ Run the Java scaffold checks from `clients/java`:
gradle test gradle test
``` ```
The `mxgateway-client` project generates the gateway and worker protobuf/gRPC The `zb-mom-ww-mxgateway-client` project generates the gateway and worker
bindings into `src/main/generated`, compiles the generated contracts, and runs protobuf/gRPC bindings into `src/main/generated`, compiles the generated
JUnit 5 tests. The `mxgateway-cli` project builds a Picocli-based `mxgw-java` contracts, and runs JUnit 5 tests. The `zb-mom-ww-mxgateway-cli` project
entry point for later command implementation. builds a Picocli-based `mxgw-java` entry point for later command implementation.
## Related Documentation ## Related Documentation
+36 -77
View File
@@ -10,22 +10,23 @@ clients/java/
settings.gradle settings.gradle
build.gradle build.gradle
src/main/generated/ src/main/generated/
mxgateway-client/ zb-mom-ww-mxgateway-client/
mxgateway-cli/ zb-mom-ww-mxgateway-cli/
``` ```
`mxgateway-client` generates Java protobuf and gRPC sources from `zb-mom-ww-mxgateway-client` generates Java protobuf and gRPC sources from
`../../src/MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those `../../src/ZB.MOM.WW.MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
generated sources under `src/main/generated`, which matches the client proto generated sources under `src/main/generated`, which matches the client proto
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand. manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
`mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`, `zb-mom-ww-mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw `MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
generated stubs, and generated protobuf messages for parity tests. generated stubs, and generated protobuf messages for parity tests.
`mxgateway-cli` depends on `mxgateway-client` and provides the `mxgw-java` `zb-mom-ww-mxgateway-cli` depends on `zb-mom-ww-mxgateway-client` and provides
application entry point. The CLI supports version, session, command, event the `mxgw-java` application entry point. The CLI supports version, session,
streaming, write, and smoke-test commands with deterministic JSON output. command, event streaming, write, and smoke-test commands with deterministic
JSON output.
## Regenerating Protobuf Bindings ## Regenerating Protobuf Bindings
@@ -33,7 +34,7 @@ Run generation from `clients/java` after the shared `.proto` files or Java
output path changes: output path changes:
```powershell ```powershell
gradle :mxgateway-client:generateProto gradle :zb-mom-ww-mxgateway-client:generateProto
``` ```
## Client Usage ## Client Usage
@@ -62,60 +63,16 @@ underlying protobuf messages. `MxGatewayCommandException` and
`MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a `MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a
data-bearing MXAccess failure. data-bearing MXAccess failure.
`MxGatewaySession` exposes the full bulk family — `addItemBulk`,
`adviseItemBulk`, `removeItemBulk`, `unAdviseItemBulk`, `subscribeBulk`,
`unsubscribeBulk`, `writeBulk`, `write2Bulk`, `writeSecuredBulk`,
`writeSecured2Bulk`, and `readBulk`. Each carries one round-trip with a
`List<*Entry>` (or `List<String>` / `List<Integer>` for the legacy bulk
shapes) and returns `List<SubscribeResult>` / `List<BulkWriteResult>` /
`List<BulkReadResult>`; per-entry MXAccess failures populate
`wasSuccessful == false` and never throw. `readBulk` takes a per-tag
`timeoutMs` (0 = worker default) and returns cached `OnDataChange` values
when the tag is already advised (`wasCached == true`) without touching the
existing subscription.
`openSession` verifies the gateway's reported `gateway_protocol_version` against
the version this client was generated for and throws `MxGatewayException` on a
mismatch, so an incompatible client fails fast with a clear message instead of
issuing commands that fail downstream. A gateway that does not populate the
field is accepted unchanged.
`MxGatewaySession` implements `AutoCloseable`. The try-with-resources `close()`
performs a `CloseSession` network RPC but swallows (and logs) any failure of
that RPC so a close-time error never replaces the exception a try-with-resources
body is already propagating. Call `closeRaw()` explicitly when you need to
observe the close result or handle a close-time failure.
`MxGatewayClient` and `GalaxyRepositoryClient` implement `AutoCloseable`. For a
client that owns its channel (built with `connect`), the try-with-resources
`close()` shuts the channel down and waits up to the configured
`shutdownTimeout` (default 10 s, independent of `connectTimeout`) for
termination, forcibly shutting it down on timeout, so in-flight calls and
Netty event-loop threads are not left running after the block exits. If the
calling thread is interrupted while waiting, the channel is forcibly shut down
and the interrupt flag is restored. `closeAndAwaitTermination()` does the same
but throws `InterruptedException` for callers that want a checked,
blocking-aware shutdown. `close()` is a no-op for a caller-managed channel.
`MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it `MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it
cancels the underlying gRPC stream. Canceling or timing out a Java client call cancels the underlying gRPC stream. Canceling or timing out a Java client call
only stops the client from waiting; it does not abort an in-flight MXAccess COM only stops the client from waiting; it does not abort an in-flight MXAccess COM
call on the worker STA. Closing an `MxEventStream` *before* the gRPC call has call on the worker STA.
attached its observer (a real race when callers cancel immediately after
subscribing) is safe — the close is replayed in the observer's `beforeStart`
and the underlying call is cancelled, matching `DeployEventStream` behaviour.
The event stream uses gRPC's default auto-inbound flow control with a fixed
1024-element buffer and no client-side flow control: this is the gateway's
documented fail-fast event-backpressure model, so a consumer that stalls long
enough to fill the buffer triggers an overflow that cancels the subscription
and surfaces an `MxGatewayException` from the next `next()` call. Drain events
promptly and be prepared to resubscribe with a resume cursor.
Cancellation of `CompletableFuture` results from `openSessionAsync`, For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
`invokeAsync`, `acknowledgeAlarmAsync`, `getLastDeployTimeAsync`, `streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
`testConnectionAsync`, and `discoverHierarchyAsync` forwards to the underlying yields alarm-feed messages from the gateway's central monitor), and
gRPC call: calling `cancel(true)` on the returned future aborts the in-flight `acknowledgeAlarm` (ack by full alarm reference with an optional comment and
RPC instead of merely detaching the future from its result. ack target). Close the subscription to cancel the underlying gRPC stream.
## Galaxy Repository Browse ## Galaxy Repository Browse
@@ -154,9 +111,9 @@ The CLI exposes matching subcommands: `galaxy-test`, `galaxy-deploy-time`,
`--timeout`, and `--json` options as the gateway commands. `--timeout`, and `--json` options as the gateway commands.
```powershell ```powershell
gradle :mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
``` ```
### Watching deploy events ### Watching deploy events
@@ -206,8 +163,8 @@ The matching CLI subcommand streams events until cancelled (Ctrl+C) and prints
one line per event in text mode or one JSON object per event with `--json`: one line per event in text mode or one JSON object per event with `--json`:
```powershell ```powershell
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5" gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
``` ```
## CLI Usage ## CLI Usage
@@ -215,14 +172,16 @@ gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-k
Run the CLI through Gradle: Run the CLI through Gradle:
```powershell ```powershell
gradle :mxgateway-cli:run --args="version --json" gradle :zb-mom-ww-mxgateway-cli:run --args="version --json"
gradle :mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json" gradle :zb-mom-ww-mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json"
gradle :mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json" gradle :zb-mom-ww-mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json"
gradle :mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json" gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
gradle :mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json" gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
gradle :mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json" gradle :zb-mom-ww-mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
gradle :mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json" gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
gradle :mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json" gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --alarm-reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
``` ```
The CLI accepts `--api-key`, `--api-key-env`, `--plaintext`, `--ca-file`, The CLI accepts `--api-key`, `--api-key-env`, `--plaintext`, `--ca-file`,
@@ -232,7 +191,7 @@ output redacts API keys.
Use TLS options for a secured gateway: Use TLS options for a secured gateway:
```powershell ```powershell
gradle :mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestObject.TestInt --json" gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestObject.TestInt --json"
``` ```
## Build And Test ## Build And Test
@@ -252,11 +211,11 @@ in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
Create local library and CLI artifacts from `clients/java`: Create local library and CLI artifacts from `clients/java`:
```powershell ```powershell
gradle :mxgateway-client:jar :mxgateway-cli:installDist gradle :zb-mom-ww-mxgateway-client:jar :zb-mom-ww-mxgateway-cli:installDist
``` ```
The library jar is under `mxgateway-client/build/libs`. The installed CLI The library jar is under `zb-mom-ww-mxgateway-client/build/libs`. The installed CLI
distribution is under `mxgateway-cli/build/install/mxgateway-cli`. distribution is under `zb-mom-ww-mxgateway-cli/build/install/zb-mom-ww-mxgateway-cli`.
## Integration Checks ## Integration Checks
@@ -267,7 +226,7 @@ $env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'localhost:5000' $env:MXGATEWAY_ENDPOINT = 'localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>' $env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt' $env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
gradle :mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json" gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
``` ```
## Related Documentation ## Related Documentation
+1 -1
View File
@@ -12,7 +12,7 @@ ext {
} }
subprojects { subprojects {
group = 'com.dohertylan.mxgateway' group = 'com.zb.mom.ww.mxgateway'
version = '0.1.0' version = '0.1.0'
pluginManager.withPlugin('java') { pluginManager.withPlugin('java') {
@@ -1,321 +0,0 @@
package com.dohertylan.mxgateway.client;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import io.grpc.ManagedChannel;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import io.grpc.stub.AbstractStub;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import javax.net.ssl.SSLException;
/**
* Shared channel-builder and future-adaptor helpers used by both
* {@link MxGatewayClient} and {@link GalaxyRepositoryClient}.
*
* <p>Extracted so transport construction, per-call deadlines, and the
* {@link ListenableFuture}-to-{@link CompletableFuture} bridge live in one
* place instead of being duplicated verbatim across the two clients.
*/
final class MxGatewayChannels {
private MxGatewayChannels() {
}
/**
* Builds a Netty managed channel from the supplied options, applying the
* connect timeout, message-size limit, and the configured transport
* security mode (plaintext, custom CA trust, or system trust).
*
* @param options the client options carrying endpoint and transport config
* @param tlsErrorPrefix a human-readable prefix for the {@link MxGatewayException}
* thrown when a custom CA certificate cannot be loaded
* @return a new managed channel; the caller owns its lifecycle
*/
static ManagedChannel createChannel(MxGatewayClientOptions options, String tlsErrorPrefix) {
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
.maxInboundMessageSize(options.maxGrpcMessageBytes());
if (!options.connectTimeout().isNegative()) {
builder.withOption(
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
Math.toIntExact(options.connectTimeout().toMillis()));
}
if (options.plaintext()) {
builder.usePlaintext();
} else if (options.caCertificatePath() != null) {
try {
builder.sslContext(GrpcSslContexts.forClient()
.trustManager(options.caCertificatePath().toFile())
.build());
} catch (SSLException | RuntimeException error) {
// SSLException covers handshake-context failures; RuntimeException
// (IllegalArgumentException wrapping CertificateException) covers a
// missing or unreadable CA file. Either way callers see one typed
// failure instead of a raw, unwrapped exception leaking out.
throw new MxGatewayException(tlsErrorPrefix, error);
}
} else {
builder.useTransportSecurity();
}
if (!options.serverNameOverride().isBlank()) {
builder.overrideAuthority(options.serverNameOverride());
}
return builder.build();
}
/**
* Applies the configured per-call deadline to a unary stub.
*
* @param stub the stub to decorate
* @param options the client options carrying the call timeout
* @param <T> the concrete stub type
* @return the stub with the call deadline applied, or the stub unchanged
* when the call timeout is negative (disabled)
*/
static <T extends AbstractStub<T>> T withDeadline(T stub, MxGatewayClientOptions options) {
if (options.callTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
/**
* Applies the configured streaming deadline to a streaming stub.
*
* @param stub the stub to decorate
* @param options the client options carrying the stream timeout
* @param <T> the concrete stub type
* @return the stub with the stream deadline applied, or the stub unchanged
* when the stream timeout is unset or negative (disabled)
*/
static <T extends AbstractStub<T>> T withStreamDeadline(T stub, MxGatewayClientOptions options) {
if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
/**
* Shuts a client-owned channel down and waits up to the configured
* {@link MxGatewayClientOptions#shutdownTimeout()} for graceful
* termination, forcing {@code shutdownNow()} on timeout. If the calling
* thread is interrupted while waiting, the channel is forcibly shut down
* and the thread's interrupt flag is restored this matches the
* try-with-resources {@code close()} contract that cannot throw a checked
* exception.
*
* <p>No-op when {@code ownedChannel} is {@code null} (i.e. the caller owns
* the channel lifecycle on a borrowed channel).
*
* @param ownedChannel the channel to shut down, may be {@code null}
* @param options the client options carrying the shutdown timeout
*/
static void shutdown(ManagedChannel ownedChannel, MxGatewayClientOptions options) {
if (ownedChannel == null) {
return;
}
ownedChannel.shutdown();
try {
if (!ownedChannel.awaitTermination(options.shutdownTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
ownedChannel.shutdownNow();
}
} catch (InterruptedException error) {
ownedChannel.shutdownNow();
Thread.currentThread().interrupt();
}
}
/**
* Shuts a client-owned channel down and waits up to the configured
* {@link MxGatewayClientOptions#shutdownTimeout()} for termination,
* forcing {@code shutdownNow()} on timeout. Throws
* {@link InterruptedException} when the calling thread is interrupted
* for callers that want a checked, blocking-aware shutdown.
*
* <p>No-op when {@code ownedChannel} is {@code null}.
*
* @param ownedChannel the channel to shut down, may be {@code null}
* @param options the client options carrying the shutdown timeout
* @throws InterruptedException if the calling thread is interrupted while waiting
*/
static void shutdownAndAwaitTermination(ManagedChannel ownedChannel, MxGatewayClientOptions options)
throws InterruptedException {
if (ownedChannel == null) {
return;
}
ownedChannel.shutdown();
if (!ownedChannel.awaitTermination(options.shutdownTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
ownedChannel.shutdownNow();
}
}
/**
* Bridges a Guava {@link ListenableFuture} to a {@link CompletableFuture},
* normalising any failure through {@link MxGatewayErrors#fromGrpc} so the
* async error surface matches the synchronous methods. Cancelling the
* returned future cancels the source RPC.
*
* <p><strong>Cancellation contract:</strong> the returned future is a
* {@link CancellingCompletableFuture} that overrides
* {@link CompletableFuture#cancel(boolean)} so cancelling the
* <em>direct return value</em> forwards to the source
* {@link ListenableFuture}, aborting the underlying gRPC call. This is the
* fix for Client.Java-015.
*
* <p><strong>Important derived stages do <em>not</em> propagate
* cancellation upstream.</strong> Calling
* {@code cancel(...)} on a future obtained via
* {@code thenApply}/{@code thenCompose}/{@code thenAccept}/{@code whenComplete}
* of the value returned by this method only marks <em>that</em> derived stage
* as cancelled; it does <strong>not</strong> propagate back to this
* {@code CancellingCompletableFuture}, so the source RPC continues until its
* deadline expires. {@link CompletableFuture#thenApply} (and the other
* chaining methods) deliberately do not forward cancellation to the upstream
* stage they were derived from.
*
* <p>If a caller needs cancellation through a chained pipeline, either:
* <ul>
* <li>use the {@link #toCompletable(ListenableFuture, String, Function)}
* overload below, which inlines a validator into the
* {@code FutureCallback} so the user-visible future is the same
* future cancellation is bound to (this is what the {@code *Async}
* methods on {@link MxGatewayClient} and the unary methods on
* {@link GalaxyRepositoryClient} do); or</li>
* <li>follow {@link GalaxyRepositoryClient#discoverHierarchyAsync}'s
* pattern of returning a custom {@link CompletableFuture} subclass
* that tracks the current in-flight stage via an
* {@link java.util.concurrent.atomic.AtomicReference} and forwards
* {@code cancel(...)} to it (necessary when chaining
* {@code thenCompose} stages across paged calls).</li>
* </ul>
*
* @param source the gRPC future-stub result
* @param operation the operation name used in normalised error messages
* @param <T> the reply type
* @return a completable future mirroring the source
*/
static <T> CompletableFuture<T> toCompletable(ListenableFuture<T> source, String operation) {
CancellingCompletableFuture<T> target = new CancellingCompletableFuture<>(source);
Futures.addCallback(
source,
new FutureCallback<>() {
@Override
public void onSuccess(T result) {
target.complete(result);
}
@Override
public void onFailure(Throwable error) {
if (error instanceof RuntimeException runtimeException) {
target.completeExceptionally(MxGatewayErrors.fromGrpc(operation, runtimeException));
return;
}
target.completeExceptionally(error);
}
},
MoreExecutors.directExecutor());
return target;
}
/**
* Bridges a Guava {@link ListenableFuture} to a {@link CompletableFuture}
* and applies {@code validator} to the reply inline (i.e. without a
* downstream {@code thenApply}), so the user-visible future is the same
* future cancellation is bound to. Any non-{@link MxGatewayException}
* {@link RuntimeException} thrown by {@code validator} is routed through
* {@link MxGatewayErrors#fromGrpc} to match the synchronous error surface.
*
* <p>This overload exists because the prior {@code toCompletable(...)
* .thenApply(validator)} pattern broke cancellation propagation: the
* future returned by {@code thenApply} is a new stage whose cancellation
* does not propagate to the underlying gRPC call. Using this overload, the
* single returned future is the one users hold, so calling {@code cancel}
* on it forwards to the source RPC.
*
* @param source the gRPC future-stub result
* @param operation the operation name used in normalised error messages
* @param validator the validating/transforming function applied to the reply
* @param <T> the reply type
* @param <R> the validated/transformed result type
* @return a completable future mirroring the validated source
*/
static <T, R> CompletableFuture<R> toCompletable(
ListenableFuture<T> source, String operation, Function<T, R> validator) {
CancellingCompletableFuture<R> target = new CancellingCompletableFuture<>(source);
Futures.addCallback(
source,
new FutureCallback<>() {
@Override
public void onSuccess(T result) {
try {
target.complete(validator.apply(result));
} catch (MxGatewayException error) {
target.completeExceptionally(error);
} catch (RuntimeException error) {
target.completeExceptionally(MxGatewayErrors.fromGrpc(operation, error));
}
}
@Override
public void onFailure(Throwable error) {
if (error instanceof RuntimeException runtimeException) {
target.completeExceptionally(MxGatewayErrors.fromGrpc(operation, runtimeException));
return;
}
target.completeExceptionally(error);
}
},
MoreExecutors.directExecutor());
return target;
}
/**
* {@link CompletableFuture} subclass that forwards {@link #cancel(boolean)}
* to a backing {@link ListenableFuture}. Used by {@link #toCompletable} so
* cancelling the user-visible future cancels the underlying gRPC call.
*/
static final class CancellingCompletableFuture<T> extends CompletableFuture<T> {
private final ListenableFuture<?> source;
CancellingCompletableFuture(ListenableFuture<?> source) {
this.source = source;
}
@Override
public boolean cancel(boolean mayInterruptIfRunning) {
boolean cancelled = super.cancel(mayInterruptIfRunning);
// Always forward; the source future is idempotent on cancel and the
// user contract is that cancelling the future cancels the RPC.
source.cancel(mayInterruptIfRunning);
return cancelled;
}
}
/**
* Adapts a reply-validating function for use inside {@code thenApply} so
* any non-{@link MxGatewayException} {@link RuntimeException} it raises is
* routed through {@link MxGatewayErrors#fromGrpc}. This keeps the async
* error surface consistent with the synchronous methods, which normalise
* failures with a {@code try/catch}.
*
* @param operation the operation name used in normalised error messages
* @param validator the validating/transforming function applied to the reply
* @param <T> the reply type
* @param <R> the result type
* @return a function suitable for {@link CompletableFuture#thenApply}
*/
static <T, R> Function<T, R> normalisingValidator(String operation, Function<T, R> validator) {
return reply -> {
try {
return validator.apply(reply);
} catch (MxGatewayException error) {
throw error;
} catch (RuntimeException error) {
throw MxGatewayErrors.fromGrpc(operation, error);
}
};
}
}
@@ -1,81 +0,0 @@
package com.dohertylan.mxgateway.client;
import java.util.regex.Pattern;
/**
* Helpers for redacting secrets such as gateway API keys from log output.
*
* <p>API keys must never reach logs in plaintext. The methods on this class
* produce shortened, masked forms safe for diagnostic messages.
*/
public final class MxGatewaySecrets {
// Match any gateway-shaped credential anywhere in the string, regardless of
// surrounding punctuation: quoted, colon/comma-delimited, embedded in URLs
// or parens. The underscore-separated character class also covers a
// trailing hyphen in case a future key format introduces one.
private static final Pattern MXGW_TOKEN = Pattern.compile("mxgw_[A-Za-z0-9_-]+");
// Mask the token after a Bearer marker as a unit so callers cannot
// accidentally leak the secret when the surrounding text is a header-style
// string (e.g. "Bearer mxgw_id_secret").
private static final Pattern BEARER_TOKEN = Pattern.compile("(?i)bearer\\s+\\S+");
private MxGatewaySecrets() {
}
/**
* Redacts the secret portion of an API key, leaving only the non-secret
* key identifier visible so the value remains comparable in logs.
*
* <p>A gateway API key has the form {@code mxgw_<key-id>_<secret>}. Only the
* {@code mxgw_<key-id>_} prefix is non-secret; everything after the second
* underscore is the secret and is masked entirely &mdash; no leading or
* trailing characters of the secret are echoed. Tokens that do not match
* the gateway shape are masked completely as {@code "<redacted>"}.
*
* @param apiKey the API key to redact, may be {@code null} or empty
* @return an empty string for {@code null}/empty input, {@code "<redacted>"}
* for non-gateway-shaped tokens, or {@code mxgw_<key-id>_***} with the
* secret masked for gateway-shaped keys
*/
public static String redactApiKey(String apiKey) {
if (apiKey == null || apiKey.isEmpty()) {
return "";
}
// Gateway keys are mxgw_<key-id>_<secret>; keep only the non-secret prefix.
if (apiKey.startsWith("mxgw_")) {
int secretSeparator = apiKey.indexOf('_', "mxgw_".length());
if (secretSeparator >= 0 && secretSeparator < apiKey.length() - 1) {
return apiKey.substring(0, secretSeparator + 1) + "***";
}
}
// Anything else is treated as wholly secret reveal nothing.
return "<redacted>";
}
/**
* Replaces gateway-style credential tokens inside a free-form string with a
* redaction placeholder.
*
* <p>Matches any {@code mxgw_<...>} token anywhere in the string,
* irrespective of surrounding punctuation (whitespace, colons, commas,
* single/double quotes, parentheses, embedded URL paths). Also masks the
* argument of an authorization-header style {@code Bearer <token>} marker
* as a unit so the token cannot leak through when the surrounding string
* is a raw header value.
*
* @param value the string to scrub, may be {@code null}
* @return an empty string for {@code null}, the original value when blank,
* or the value with credential tokens replaced by {@code "<redacted>"}
*/
public static String redactCredentials(String value) {
if (value == null || value.isBlank()) {
return value == null ? "" : value;
}
String scrubbed = MXGW_TOKEN.matcher(value).replaceAll("<redacted>");
scrubbed = BEARER_TOKEN.matcher(scrubbed).replaceAll("Bearer <redacted>");
return scrubbed;
}
}
@@ -1,182 +0,0 @@
package com.dohertylan.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.assertTrue;
import io.grpc.CallOptions;
import io.grpc.ClientCall;
import io.grpc.ConnectivityState;
import io.grpc.ManagedChannel;
import io.grpc.MethodDescriptor;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
/**
* Regression tests for the second-pass Low-severity Client.Java findings
* Client.Java-016, Client.Java-019, and the shared shutdown helpers extracted
* to {@link MxGatewayChannels}.
*/
final class MxGatewayLowFindingsIITests {
// --- Client.Java-019: shutdown timeout is independent of connect timeout ---
@Test
void shutdownAndAwaitTerminationHonoursShutdownTimeoutNotConnectTimeout() throws Exception {
// The historical bug: close() used connectTimeout as the awaitTermination
// deadline, so a small connectTimeout forced a premature shutdownNow()
// on in-flight calls. The fix uses a dedicated shutdownTimeout. This
// test verifies the helper waits up to shutdownTimeout (1s) even when
// connectTimeout is set to a tiny value (50ms).
RecordingChannel channel = new RecordingChannel(/* terminatesAfterMillis = */ 200);
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("in-process")
.plaintext(true)
.connectTimeout(Duration.ofMillis(50))
.shutdownTimeout(Duration.ofSeconds(1))
.build();
long start = System.nanoTime();
MxGatewayChannels.shutdownAndAwaitTermination(channel, options);
long elapsedMillis = (System.nanoTime() - start) / 1_000_000L;
// The channel finished orderly termination within the shutdown timeout
// window, so shutdownNow() must NOT have been called. With the old
// implementation a 50ms connect-timeout-as-shutdown-deadline would
// have escalated to shutdownNow() before the channel's 200ms graceful
// termination completed.
assertTrue(channel.shutdownCalled, "shutdown() must be called");
assertFalse(
channel.shutdownNowCalled,
"graceful termination finished within shutdownTimeout; shutdownNow() must not have been called");
// Allow ample slack for build-machine variance but assert we waited at
// least the channel's graceful-termination window.
assertTrue(elapsedMillis >= 150, "should have waited for graceful termination, elapsed=" + elapsedMillis);
}
@Test
void shutdownEscalatesToShutdownNowWhenTimeoutExceeded() {
// The other half of the contract: a channel that does not terminate
// within the shutdownTimeout window must be forcibly shut down.
RecordingChannel channel = new RecordingChannel(/* terminatesAfterMillis = */ 5_000);
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("in-process")
.plaintext(true)
.shutdownTimeout(Duration.ofMillis(100))
.build();
MxGatewayChannels.shutdown(channel, options);
assertTrue(channel.shutdownCalled);
assertTrue(channel.shutdownNowCalled, "stuck channel must be forcibly shut down");
}
@Test
void shutdownTimeoutDefaultIsTenSecondsIndependentOfConnectTimeout() {
MxGatewayClientOptions defaults = MxGatewayClientOptions.builder()
.endpoint("in-process")
.build();
// Default is 10s; an unset connectTimeout-of-10s default coincides but
// the two are now independent options.
assertEquals(Duration.ofSeconds(10), defaults.shutdownTimeout());
MxGatewayClientOptions tinyConnect = MxGatewayClientOptions.builder()
.endpoint("in-process")
.connectTimeout(Duration.ofMillis(500))
.build();
assertEquals(Duration.ofSeconds(10), tinyConnect.shutdownTimeout(),
"shutdownTimeout default is independent of connectTimeout");
}
// --- Client.Java-016: shared shutdown helpers behave identically for both clients ---
@Test
void sharedShutdownHelperIsNoOpForNullChannel() throws Exception {
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("in-process")
.plaintext(true)
.shutdownTimeout(Duration.ofMillis(50))
.build();
// Both helpers must tolerate a null owned-channel (caller-managed channel case).
MxGatewayChannels.shutdown(null, options);
MxGatewayChannels.shutdownAndAwaitTermination(null, options);
}
/**
* Test double for {@link ManagedChannel} that records {@code shutdown}/
* {@code shutdownNow} invocations and simulates an orderly termination
* after a configurable delay. Avoids the heavy in-process gRPC machinery
* the shutdown helpers only touch the three lifecycle methods.
*/
private static final class RecordingChannel extends ManagedChannel {
private final long terminatesAfterMillis;
private final long createdAtNanos;
private volatile boolean shutdownCalled;
private volatile boolean shutdownNowCalled;
RecordingChannel(long terminatesAfterMillis) {
this.terminatesAfterMillis = terminatesAfterMillis;
this.createdAtNanos = System.nanoTime();
}
@Override
public ManagedChannel shutdown() {
shutdownCalled = true;
return this;
}
@Override
public boolean isShutdown() {
return shutdownCalled || shutdownNowCalled;
}
@Override
public boolean isTerminated() {
if (shutdownNowCalled) {
return true;
}
if (!shutdownCalled) {
return false;
}
long elapsed = (System.nanoTime() - createdAtNanos) / 1_000_000L;
return elapsed >= terminatesAfterMillis;
}
@Override
public ManagedChannel shutdownNow() {
shutdownNowCalled = true;
return this;
}
@Override
public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
long deadlineNanos = System.nanoTime() + unit.toNanos(timeout);
while (System.nanoTime() < deadlineNanos) {
if (isTerminated()) {
return true;
}
long remaining = Math.max(1, (deadlineNanos - System.nanoTime()) / 1_000_000L);
Thread.sleep(Math.min(remaining, 10));
}
return isTerminated();
}
@Override
public <RequestT, ResponseT> ClientCall<RequestT, ResponseT> newCall(
MethodDescriptor<RequestT, ResponseT> methodDescriptor, CallOptions callOptions) {
throw new UnsupportedOperationException("no RPCs are issued in shutdown tests");
}
@Override
public String authority() {
return "in-process";
}
@Override
public ConnectivityState getState(boolean requestConnection) {
return ConnectivityState.IDLE;
}
}
}
@@ -1,509 +0,0 @@
package com.dohertylan.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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.grpc.ManagedChannel;
import io.grpc.Server;
import io.grpc.Status;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver;
import io.grpc.stub.StreamObserver;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
import org.junit.jupiter.api.Test;
/**
* Regression tests for the Low-severity Client.Java code-review findings
* (Client.Java-006 through Client.Java-012). Covers the alarm RPC surface,
* async streaming/subscription cancellation, queue overflow, and TLS-config
* construction that Client.Java-007 reports as untested.
*/
final class MxGatewayLowFindingsTests {
// --- Client.Java-007: AcknowledgeAlarm RPC coverage ---
@Test
void acknowledgeAlarmReturnsReplyAndSendsAuthMetadata() throws Exception {
AtomicReference<String> authorization = new AtomicReference<>();
AtomicReference<AcknowledgeAlarmRequest> seen = new AtomicReference<>();
TestService service = new TestService() {
@Override
public void acknowledgeAlarm(
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
seen.set(request);
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
.setProtocolStatus(ok())
.setDiagnosticMessage("acked")
.build());
responseObserver.onCompleted();
}
};
try (Harness harness = Harness.start(service, "mxgw_keyid_secret", authorization)) {
AcknowledgeAlarmReply reply = harness.client().acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
.setAlarmFullReference("Area1.Pump.PV.HiHi")
.setComment("operator note")
.build());
assertEquals("acked", reply.getDiagnosticMessage());
assertEquals("Area1.Pump.PV.HiHi", seen.get().getAlarmFullReference());
assertEquals("Bearer mxgw_keyid_secret", authorization.get());
}
}
@Test
void acknowledgeAlarmThrowsTypedExceptionOnProtocolFailure() throws Exception {
TestService service = new TestService() {
@Override
public void acknowledgeAlarm(
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
.setProtocolStatus(ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND))
.build());
responseObserver.onCompleted();
}
};
try (Harness harness = Harness.start(service)) {
assertThrows(
MxGatewayException.class,
() -> harness.client().acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
.setAlarmFullReference("Area1.Pump.PV.HiHi")
.build()));
}
}
@Test
void acknowledgeAlarmAsyncCompletesWithReply() throws Exception {
TestService service = new TestService() {
@Override
public void acknowledgeAlarm(
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
.setProtocolStatus(ok())
.setDiagnosticMessage("async-acked")
.build());
responseObserver.onCompleted();
}
};
try (Harness harness = Harness.start(service)) {
CompletableFuture<AcknowledgeAlarmReply> future = harness.client()
.acknowledgeAlarmAsync(AcknowledgeAlarmRequest.newBuilder()
.setAlarmFullReference("Area1.Pump.PV.HiHi")
.build());
assertEquals("async-acked", future.get(5, TimeUnit.SECONDS).getDiagnosticMessage());
}
}
@Test
void acknowledgeAlarmAsyncFailsExceptionallyWithTypedException() throws Exception {
TestService service = new TestService() {
@Override
public void acknowledgeAlarm(
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
responseObserver.onError(Status.UNAVAILABLE.withDescription("worker down").asRuntimeException());
}
};
try (Harness harness = Harness.start(service)) {
CompletableFuture<AcknowledgeAlarmReply> future = harness.client()
.acknowledgeAlarmAsync(AcknowledgeAlarmRequest.newBuilder()
.setAlarmFullReference("Area1.Pump.PV.HiHi")
.build());
ExecutionException error = assertThrows(
ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS));
assertTrue(error.getCause() instanceof MxGatewayException, () -> String.valueOf(error.getCause()));
}
}
// --- Client.Java-007: StreamAlarms RPC + subscription coverage ---
@Test
void streamAlarmsDeliversFeedMessagesToObserver() throws Exception {
AlarmFeedMessage active = AlarmFeedMessage.newBuilder()
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
.setAlarmFullReference("Area1.Tank.Level.Hi")
.setSeverity(800)
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE))
.build();
AlarmFeedMessage snapshotComplete =
AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build();
TestService service = new TestService() {
@Override
public void streamAlarms(
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> responseObserver) {
responseObserver.onNext(active);
responseObserver.onNext(snapshotComplete);
responseObserver.onCompleted();
}
};
try (Harness harness = Harness.start(service)) {
List<AlarmFeedMessage> received = new ArrayList<>();
CountDownLatch done = new CountDownLatch(1);
harness.client().streamAlarms(
StreamAlarmsRequest.newBuilder().build(),
new StreamObserver<>() {
@Override
public void onNext(AlarmFeedMessage value) {
received.add(value);
}
@Override
public void onError(Throwable t) {
done.countDown();
}
@Override
public void onCompleted() {
done.countDown();
}
});
assertTrue(done.await(5, TimeUnit.SECONDS), "stream should complete");
assertEquals(2, received.size());
assertEquals("Area1.Tank.Level.Hi", received.get(0).getActiveAlarm().getAlarmFullReference());
assertTrue(received.get(1).getSnapshotComplete());
}
}
@Test
void alarmFeedSubscriptionCancelBeforeBeforeStartCancelsStream() {
MxGatewayAlarmFeedSubscription subscription = new MxGatewayAlarmFeedSubscription();
ClientResponseObserver<StreamAlarmsRequest, AlarmFeedMessage> observer =
subscription.wrap(new StreamObserver<>() {
@Override
public void onNext(AlarmFeedMessage value) {
}
@Override
public void onError(Throwable t) {
}
@Override
public void onCompleted() {
}
});
RecordingAlarmFeedRequestStream requestStream = new RecordingAlarmFeedRequestStream();
subscription.cancel();
observer.beforeStart(requestStream);
assertTrue(requestStream.cancelled);
assertEquals("client cancelled alarm feed", requestStream.cancelMessage);
}
// --- Client.Java-007: async streamEvents + subscription cancellation ---
@Test
void streamEventsAsyncDeliversEventsToObserver() throws Exception {
MxEvent event = MxEvent.newBuilder().setWorkerSequence(7).build();
TestService service = new TestService() {
@Override
public void streamEvents(StreamEventsRequest request, StreamObserver<MxEvent> responseObserver) {
responseObserver.onNext(event);
responseObserver.onCompleted();
}
};
try (Harness harness = Harness.start(service)) {
List<MxEvent> received = new ArrayList<>();
CountDownLatch done = new CountDownLatch(1);
harness.client().streamEventsAsync(
StreamEventsRequest.newBuilder().setSessionId("s-5").build(),
new StreamObserver<>() {
@Override
public void onNext(MxEvent value) {
received.add(value);
}
@Override
public void onError(Throwable t) {
done.countDown();
}
@Override
public void onCompleted() {
done.countDown();
}
});
assertTrue(done.await(5, TimeUnit.SECONDS), "stream should complete");
assertEquals(1, received.size());
assertEquals(7, received.get(0).getWorkerSequence());
}
}
@Test
void eventSubscriptionCancelBeforeBeforeStartCancelsStream() {
MxGatewayEventSubscription subscription = new MxGatewayEventSubscription();
ClientResponseObserver<StreamEventsRequest, MxEvent> observer =
subscription.wrap(new StreamObserver<>() {
@Override
public void onNext(MxEvent value) {
}
@Override
public void onError(Throwable t) {
}
@Override
public void onCompleted() {
}
});
RecordingEventsRequestStream requestStream = new RecordingEventsRequestStream();
subscription.cancel();
observer.beforeStart(requestStream);
assertTrue(requestStream.cancelled);
assertEquals("client cancelled event stream", requestStream.cancelMessage);
}
// --- Client.Java-007 / Client.Java-011: MxEventStream queue overflow ---
@Test
void eventStreamQueueOverflowSurfacesExceptionFromNext() {
MxEventStream stream = new MxEventStream(2);
ClientResponseObserver<StreamEventsRequest, MxEvent> observer = stream.observer();
RecordingEventsRequestStream requestStream = new RecordingEventsRequestStream();
observer.beforeStart(requestStream);
// Push far more events than the capacity-2 buffer can hold without draining.
for (int i = 0; i < 16; i++) {
observer.onNext(MxEvent.newBuilder().setWorkerSequence(i).build());
}
// Overflow must cancel the gRPC call and surface as MxGatewayException.
assertTrue(requestStream.cancelled, "overflow should cancel the underlying call");
MxGatewayException error = assertThrows(MxGatewayException.class, () -> {
while (stream.hasNext()) {
stream.next();
}
});
assertTrue(error.getMessage().contains("overflow"), error::getMessage);
}
// --- Client.Java-007: TLS channel construction ---
@Test
void connectWithMissingCaCertificateThrowsTypedTlsException() {
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5001")
.apiKey("mxgw_id_secret")
.plaintext(false)
.caCertificatePath(Path.of("does-not-exist-" + UUID.randomUUID() + ".pem"))
.build();
MxGatewayException error = assertThrows(MxGatewayException.class, () -> MxGatewayClient.connect(options));
assertTrue(error.getMessage().contains("TLS"), error::getMessage);
MxGatewayException galaxyError =
assertThrows(MxGatewayException.class, () -> GalaxyRepositoryClient.connect(options));
assertTrue(galaxyError.getMessage().contains("TLS"), galaxyError::getMessage);
}
@Test
void connectWithSystemTrustBuildsTlsChannelWithoutError() {
// No CA path and plaintext=false exercises the useTransportSecurity() branch.
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5001")
.apiKey("mxgw_id_secret")
.plaintext(false)
.build();
try (MxGatewayClient client = MxGatewayClient.connect(options)) {
assertNotNull(client);
}
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
assertNotNull(galaxy);
}
}
// --- Client.Java-008: async error surface is normalised ---
@Test
void openSessionAsyncNormalisesNonGatewayRuntimeExceptionFromValidator() {
// ensureGatewayProtocolCompatible already throws MxGatewayException; this verifies
// the normalisingValidator wrapper routes a stray RuntimeException through fromGrpc.
CompletableFuture<String> source = new CompletableFuture<>();
CompletableFuture<String> wrapped =
source.thenApply(MxGatewayChannels.normalisingValidator("open session", reply -> {
throw new IllegalStateException("malformed reply");
}));
source.complete("payload");
CompletionException error = assertThrows(CompletionException.class, wrapped::join);
assertTrue(error.getCause() instanceof MxGatewayException, () -> String.valueOf(error.getCause()));
}
private static ProtocolStatus ok() {
return ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
.build();
}
private static class TestService extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
}
private record Harness(Server server, ManagedChannel channel, MxGatewayClient client) implements AutoCloseable {
static Harness start(MxAccessGatewayGrpc.MxAccessGatewayImplBase service) throws Exception {
return start(service, "", new AtomicReference<>());
}
static Harness start(
MxAccessGatewayGrpc.MxAccessGatewayImplBase service,
String apiKey,
AtomicReference<String> authorization)
throws Exception {
String name = "mxgw-low-" + UUID.randomUUID();
io.grpc.ServerInterceptor interceptor = new io.grpc.ServerInterceptor() {
@Override
public <ReqT, RespT> io.grpc.ServerCall.Listener<ReqT> interceptCall(
io.grpc.ServerCall<ReqT, RespT> call,
io.grpc.Metadata headers,
io.grpc.ServerCallHandler<ReqT, RespT> next) {
authorization.set(headers.get(MxGatewayAuthInterceptor.AUTHORIZATION_HEADER));
return next.startCall(call, headers);
}
};
Server server = InProcessServerBuilder.forName(name)
.directExecutor()
.addService(io.grpc.ServerInterceptors.intercept(service, interceptor))
.build()
.start();
ManagedChannel channel = InProcessChannelBuilder.forName(name).directExecutor().build();
MxGatewayClient client = new MxGatewayClient(
channel,
MxGatewayClientOptions.builder()
.endpoint("in-process")
.apiKey(apiKey)
.plaintext(true)
.callTimeout(Duration.ofSeconds(5))
.streamTimeout(Duration.ofSeconds(5))
.build());
return new Harness(server, channel, client);
}
@Override
public void close() {
channel.shutdownNow();
server.shutdownNow();
}
}
private static final class RecordingEventsRequestStream
extends ClientCallStreamObserver<StreamEventsRequest> {
private boolean cancelled;
private String cancelMessage;
@Override
public void cancel(String message, Throwable cause) {
cancelled = true;
cancelMessage = message;
}
@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() {
}
}
private static final class RecordingAlarmFeedRequestStream
extends ClientCallStreamObserver<StreamAlarmsRequest> {
private boolean cancelled;
private String cancelMessage;
@Override
public void cancel(String message, Throwable cause) {
cancelled = true;
cancelMessage = message;
}
@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(StreamAlarmsRequest value) {
}
@Override
public void onError(Throwable t) {
}
@Override
public void onCompleted() {
}
}
}
@@ -1,527 +0,0 @@
package com.dohertylan.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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.grpc.ManagedChannel;
import io.grpc.Server;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.stub.StreamObserver;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import org.junit.jupiter.api.Test;
/**
* Regression tests for the Medium-severity Client.Java code-review findings
* (Client.Java-001 through Client.Java-005, and Client.Java-014/015).
*/
final class MxGatewayMediumFindingsTests {
// --- Client.Java-001: redactApiKey must not leak trailing secret chars ---
@Test
void redactApiKeyDoesNotLeakAnyCharacterOfTheSecret() {
// mxgw_<key-id>_<secret> the secret is the segment after the second underscore.
String apiKey = "mxgw_keyid01_supersecretvalue";
String redacted = MxGatewaySecrets.redactApiKey(apiKey);
// None of the secret characters may appear in the redacted output.
assertFalse(redacted.contains("value"), () -> "redacted form leaked secret tail: " + redacted);
assertFalse(redacted.endsWith("alue"), () -> "redacted form leaked trailing secret chars: " + redacted);
assertFalse(redacted.contains("supersecret"), () -> "redacted form leaked secret: " + redacted);
// The non-secret key-id prefix may stay so the value is still comparable in logs.
assertTrue(redacted.startsWith("mxgw_keyid01_"), () -> "redacted form lost key-id prefix: " + redacted);
}
@Test
void redactApiKeyForNonGatewayShapedKeyRevealsNothing() {
String redacted = MxGatewaySecrets.redactApiKey("plain-opaque-token-1234");
assertFalse(redacted.contains("1234"), () -> "redacted form leaked trailing chars: " + redacted);
assertFalse(redacted.contains("plain-opaque-token"), () -> "redacted form leaked body: " + redacted);
}
@Test
void redactApiKeyStillHandlesNullAndShortInput() {
assertEquals("", MxGatewaySecrets.redactApiKey(null));
assertEquals("", MxGatewaySecrets.redactApiKey(""));
assertEquals("<redacted>", MxGatewaySecrets.redactApiKey("short"));
}
// --- Client.Java-002: terminal-state transition must be deterministic ---
@Test
void eventStreamOverflowExceptionSurvivesASubsequentClose() {
// Deterministic reproduction of Client.Java-002: an overflow enqueues the
// overflow exception, then a later close() must NOT discard it. The first
// terminal condition (overflow) must win and stay observable by next().
MxEventStream stream = new MxEventStream(2);
io.grpc.stub.ClientResponseObserver<
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
mxaccess_gateway.v1.MxaccessGateway.MxEvent>
observer = stream.observer();
observer.beforeStart(new NoopRequestStream());
// Force a queue overflow on a capacity-2 stream.
for (int i = 0; i < 8; i++) {
observer.onNext(testEvent(i));
}
// A close() arriving after the overflow must not erase the overflow signal.
stream.close();
MxGatewayException error = assertThrows(MxGatewayException.class, () -> {
while (stream.hasNext()) {
stream.next();
}
});
assertTrue(error.getMessage().contains("overflow"), error::getMessage);
}
@Test
void eventStreamConcurrentOverflowAndCloseAlwaysTerminate() throws Exception {
// The terminal-state transition must be serialised: whatever the interleaving
// of overflow and close, hasNext() always reaches a terminal state.
for (int iteration = 0; iteration < 300; iteration++) {
MxEventStream stream = new MxEventStream(2);
io.grpc.stub.ClientResponseObserver<
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
mxaccess_gateway.v1.MxaccessGateway.MxEvent>
observer = stream.observer();
observer.beforeStart(new NoopRequestStream());
Thread filler = new Thread(() -> {
for (int i = 0; i < 8; i++) {
observer.onNext(testEvent(i));
}
});
Thread closer = new Thread(stream::close);
filler.start();
closer.start();
filler.join();
closer.join();
try {
while (stream.hasNext()) {
stream.next();
}
} catch (MxGatewayException expected) {
assertTrue(expected.getMessage().contains("overflow"), expected::getMessage);
}
assertFalse(stream.hasNext());
}
}
private static final class NoopRequestStream
extends io.grpc.stub.ClientCallStreamObserver<mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest> {
@Override
public void cancel(String message, Throwable cause) {
}
@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(mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest value) {
}
@Override
public void onError(Throwable t) {
}
@Override
public void onCompleted() {
}
}
// --- Client.Java-003: gateway protocol version mismatch must be rejected ---
@Test
void openSessionRejectsIncompatibleGatewayProtocolVersion() throws Exception {
TestService service = new TestService() {
@Override
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
responseObserver.onNext(OpenSessionReply.newBuilder()
.setSessionId("session-mismatch")
.setGatewayProtocolVersion(MxGatewayClientVersion.gatewayProtocolVersion() + 1)
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
};
try (Harness harness = Harness.start(service)) {
MxGatewayException error = assertThrows(
MxGatewayException.class,
() -> harness.client().openSession("junit-session"));
assertTrue(error.getMessage().contains("protocol version"), error::getMessage);
}
}
@Test
void openSessionAcceptsMatchingOrUnsetGatewayProtocolVersion() throws Exception {
TestService matching = new TestService() {
@Override
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
responseObserver.onNext(OpenSessionReply.newBuilder()
.setSessionId("session-ok")
.setGatewayProtocolVersion(MxGatewayClientVersion.gatewayProtocolVersion())
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
};
try (Harness harness = Harness.start(matching)) {
assertEquals("session-ok", harness.client().openSession("junit-session").sessionId());
}
// A gateway that leaves the field unset (0) must not be rejected older gateways
// simply do not populate it.
TestService unset = new TestService();
try (Harness harness = Harness.start(unset)) {
assertEquals("session-java", harness.client().openSession("junit-session").sessionId());
}
}
// --- Client.Java-004: missing typed payload AND missing return_value must throw ---
@Test
void registerThrowsWhenReplyHasNeitherTypedPayloadNorReturnValue() throws Exception {
TestService service = new TestService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
// Reply with neither register payload nor return_value set.
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
};
try (Harness harness = Harness.start(service)) {
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
MxGatewayException error = assertThrows(
MxGatewayException.class, () -> session.register("c"));
assertTrue(error.getMessage().contains("register"), error::getMessage);
}
}
@Test
void addItemThrowsWhenReplyHasNeitherTypedPayloadNorReturnValue() throws Exception {
TestService service = new TestService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
};
try (Harness harness = Harness.start(service)) {
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
assertThrows(MxGatewayException.class, () -> session.addItem(1, "Tag"));
assertThrows(MxGatewayException.class, () -> session.addItem2(1, "Tag", "ctx"));
}
}
@Test
void addItemStillHonoursReturnValueFallback() throws Exception {
TestService service = new TestService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok())
.setReturnValue(mxaccess_gateway.v1.MxaccessGateway.MxValue.newBuilder()
.setInt32Value(99))
.build());
responseObserver.onCompleted();
}
};
try (Harness harness = Harness.start(service)) {
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
assertEquals(99, session.addItem(1, "Tag"));
}
}
// --- Client.Java-005: close() must not mask the primary try-with-resources error ---
@Test
void closeSuppressesCloseTimeFailureInsteadOfMaskingBodyException() throws Exception {
TestService service = new TestService() {
@Override
public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
responseObserver.onError(io.grpc.Status.UNAVAILABLE
.withDescription("WORKER_UNAVAILABLE")
.asRuntimeException());
}
};
try (Harness harness = Harness.start(service)) {
IllegalStateException bodyError = assertThrows(IllegalStateException.class, () -> {
try (MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s")) {
throw new IllegalStateException("body failure");
}
});
// The body exception must propagate; the close-time RPC failure must not replace it.
assertEquals("body failure", bodyError.getMessage());
}
}
@Test
void closeRawStillSurfacesCloseTimeFailureForCallersWhoWantIt() throws Exception {
TestService service = new TestService() {
@Override
public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
responseObserver.onError(io.grpc.Status.UNAVAILABLE
.withDescription("WORKER_UNAVAILABLE")
.asRuntimeException());
}
};
try (Harness harness = Harness.start(service)) {
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
assertThrows(MxGatewayException.class, session::closeRaw);
}
}
// --- Client.Java-014: MxEventStream.close() before beforeStart must cancel the call ---
@Test
void mxEventStreamCloseBeforeBeforeStartCancelsStream() {
// Mirrors GalaxyRepositoryClientTests.deployEventStreamCloseBeforeBeforeStartCancelsStream:
// if close() runs before the gRPC call has attached its ClientCallStreamObserver,
// beforeStart() must observe the prior close and cancel the underlying call so the
// gRPC subscription does not leak open after the consumer has stopped iterating.
MxEventStream stream = new MxEventStream(4);
io.grpc.stub.ClientResponseObserver<
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
mxaccess_gateway.v1.MxaccessGateway.MxEvent>
observer = stream.observer();
RecordingEventsRequestStream requestStream = new RecordingEventsRequestStream();
stream.close();
observer.beforeStart(requestStream);
assertTrue(requestStream.cancelled, "beforeStart must cancel the underlying call after a prior close()");
assertEquals("client cancelled event stream", requestStream.cancelMessage);
assertFalse(stream.hasNext());
}
// --- Client.Java-015: cancelling the user-visible *Async future cancels the gRPC call ---
@Test
void invokeAsyncCancellationCancelsUnderlyingGrpcCall() throws Exception {
// Set up a gateway service that never completes the invoke call so cancellation is
// the only way the call terminates. Hook ServerCallStreamObserver.setOnCancelHandler
// to latch when the server observes cancellation.
java.util.concurrent.CountDownLatch serverCancelled = new java.util.concurrent.CountDownLatch(1);
TestService service = new TestService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
io.grpc.stub.ServerCallStreamObserver<MxCommandReply> serverObserver =
(io.grpc.stub.ServerCallStreamObserver<MxCommandReply>) responseObserver;
serverObserver.setOnCancelHandler(serverCancelled::countDown);
// Intentionally never complete the call must be terminated by the client
// cancelling its future, which must propagate to the gRPC cancellation.
}
};
try (Harness harness = Harness.start(service)) {
CompletableFuture<MxCommandReply> future = harness.client().invokeAsync(MxCommandRequest.newBuilder()
.setSessionId("s-cancel")
.setCommand(mxaccess_gateway.v1.MxaccessGateway.MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER))
.build());
// Cancellation of the user-visible future must propagate to the gRPC call.
assertTrue(future.cancel(true), "cancel(true) should return true on a pending future");
assertTrue(
serverCancelled.await(5, java.util.concurrent.TimeUnit.SECONDS),
"server must observe RPC cancellation after future.cancel(true)");
}
}
@Test
void toCompletableValidatorOverloadForwardsCancellationToSource() {
// Unit-level proof: cancel() on the future returned by the validator-aware
// toCompletable overload must call cancel(true) on the source ListenableFuture.
// This is the core fix for Client.Java-015 the validator runs inside
// toCompletable instead of via .thenApply, so the user holds the future
// that is bound to the source.
com.google.common.util.concurrent.SettableFuture<String> source =
com.google.common.util.concurrent.SettableFuture.create();
java.util.concurrent.CompletableFuture<Integer> target =
MxGatewayChannels.toCompletable(source, "noop", String::length);
assertFalse(source.isCancelled());
assertTrue(target.cancel(true));
assertTrue(source.isCancelled(), "source ListenableFuture must be cancelled");
}
@Test
void toCompletableNoValidatorOverloadForwardsCancellationToSource() {
// Regression for the no-validator overload (the historic toCompletable shape).
com.google.common.util.concurrent.SettableFuture<String> source =
com.google.common.util.concurrent.SettableFuture.create();
java.util.concurrent.CompletableFuture<String> target = MxGatewayChannels.toCompletable(source, "noop");
assertFalse(source.isCancelled());
assertTrue(target.cancel(true));
assertTrue(source.isCancelled(), "source ListenableFuture must be cancelled");
}
private static final class RecordingEventsRequestStream
extends io.grpc.stub.ClientCallStreamObserver<
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest> {
private boolean cancelled;
private String cancelMessage;
@Override
public void cancel(String message, Throwable cause) {
cancelled = true;
cancelMessage = message;
}
@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(mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest value) {
}
@Override
public void onError(Throwable t) {
}
@Override
public void onCompleted() {
}
}
private static mxaccess_gateway.v1.MxaccessGateway.MxEvent testEvent(int sequence) {
return mxaccess_gateway.v1.MxaccessGateway.MxEvent.newBuilder()
.setWorkerSequence(sequence)
.build();
}
private static ProtocolStatus ok() {
return ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
.build();
}
private static class TestService extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
@Override
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
responseObserver.onNext(OpenSessionReply.newBuilder()
.setSessionId("session-java")
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
@Override
public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
responseObserver.onNext(CloseSessionReply.newBuilder()
.setSessionId(request.getSessionId())
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(MxCommandKind.MX_COMMAND_KIND_UNSPECIFIED)
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
}
private record Harness(Server server, ManagedChannel channel, MxGatewayClient client) implements AutoCloseable {
static Harness start(MxAccessGatewayGrpc.MxAccessGatewayImplBase service) throws Exception {
String name = "mxgw-medium-" + UUID.randomUUID();
Server server = InProcessServerBuilder.forName(name)
.directExecutor()
.addService(service)
.build()
.start();
ManagedChannel channel = InProcessChannelBuilder.forName(name).directExecutor().build();
MxGatewayClient client = new MxGatewayClient(
channel,
MxGatewayClientOptions.builder()
.endpoint("in-process")
.apiKey("")
.plaintext(true)
.callTimeout(Duration.ofSeconds(5))
.build());
return new Harness(server, channel, client);
}
@Override
public void close() {
channel.shutdownNow();
server.shutdownNow();
}
}
}
+3 -3
View File
@@ -16,7 +16,7 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = 'mxaccessgw-java' rootProject.name = 'zb-mom-ww-mxaccessgw-java'
include 'mxgateway-client' include 'zb-mom-ww-mxgateway-client'
include 'mxgateway-cli' include 'zb-mom-ww-mxgateway-cli'
@@ -201,6 +201,37 @@ public final class MxAccessGatewayGrpc {
return getStreamAlarmsMethod; return getStreamAlarmsMethod;
} }
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "QueryActiveAlarms",
requestType = mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest.class,
responseType = mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot.class,
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod() {
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod;
if ((getQueryActiveAlarmsMethod = MxAccessGatewayGrpc.getQueryActiveAlarmsMethod) == null) {
synchronized (MxAccessGatewayGrpc.class) {
if ((getQueryActiveAlarmsMethod = MxAccessGatewayGrpc.getQueryActiveAlarmsMethod) == null) {
MxAccessGatewayGrpc.getQueryActiveAlarmsMethod = getQueryActiveAlarmsMethod =
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "QueryActiveAlarms"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot.getDefaultInstance()))
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("QueryActiveAlarms"))
.build();
}
}
}
return getQueryActiveAlarmsMethod;
}
/** /**
* Creates a new async stub that supports all call types for the service * Creates a new async stub that supports all call types for the service
*/ */
@@ -315,6 +346,23 @@ public final class MxAccessGatewayGrpc {
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> responseObserver) { io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamAlarmsMethod(), responseObserver); io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamAlarmsMethod(), responseObserver);
} }
/**
* <pre>
* Point-in-time snapshot of the currently-active alarm set served from the
* gateway's always-on alarm monitor cache (session-less). Used after a
* reconnect to seed Part 9 client state, or to reconcile alarms that may
* have been missed during a transport blip. Streamed so callers can
* begin processing without buffering the full set.
* `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
* snapshot to alarms whose `alarm_full_reference` starts with the given
* prefix; an empty prefix returns the full set.
* </pre>
*/
default void queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request,
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getQueryActiveAlarmsMethod(), responseObserver);
}
} }
/** /**
@@ -404,6 +452,24 @@ public final class MxAccessGatewayGrpc {
io.grpc.stub.ClientCalls.asyncServerStreamingCall( io.grpc.stub.ClientCalls.asyncServerStreamingCall(
getChannel().newCall(getStreamAlarmsMethod(), getCallOptions()), request, responseObserver); getChannel().newCall(getStreamAlarmsMethod(), getCallOptions()), request, responseObserver);
} }
/**
* <pre>
* Point-in-time snapshot of the currently-active alarm set served from the
* gateway's always-on alarm monitor cache (session-less). Used after a
* reconnect to seed Part 9 client state, or to reconcile alarms that may
* have been missed during a transport blip. Streamed so callers can
* begin processing without buffering the full set.
* `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
* snapshot to alarms whose `alarm_full_reference` starts with the given
* prefix; an empty prefix returns the full set.
* </pre>
*/
public void queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request,
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> responseObserver) {
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
getChannel().newCall(getQueryActiveAlarmsMethod(), getCallOptions()), request, responseObserver);
}
} }
/** /**
@@ -477,6 +543,25 @@ public final class MxAccessGatewayGrpc {
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall( return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
getChannel(), getStreamAlarmsMethod(), getCallOptions(), request); getChannel(), getStreamAlarmsMethod(), getCallOptions(), request);
} }
/**
* <pre>
* Point-in-time snapshot of the currently-active alarm set served from the
* gateway's always-on alarm monitor cache (session-less). Used after a
* reconnect to seed Part 9 client state, or to reconcile alarms that may
* have been missed during a transport blip. Streamed so callers can
* begin processing without buffering the full set.
* `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
* snapshot to alarms whose `alarm_full_reference` starts with the given
* prefix; an empty prefix returns the full set.
* </pre>
*/
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
public io.grpc.stub.BlockingClientCall<?, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>
queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request) {
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
getChannel(), getQueryActiveAlarmsMethod(), getCallOptions(), request);
}
} }
/** /**
@@ -548,6 +633,24 @@ public final class MxAccessGatewayGrpc {
return io.grpc.stub.ClientCalls.blockingServerStreamingCall( return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
getChannel(), getStreamAlarmsMethod(), getCallOptions(), request); getChannel(), getStreamAlarmsMethod(), getCallOptions(), request);
} }
/**
* <pre>
* Point-in-time snapshot of the currently-active alarm set served from the
* gateway's always-on alarm monitor cache (session-less). Used after a
* reconnect to seed Part 9 client state, or to reconcile alarms that may
* have been missed during a transport blip. Streamed so callers can
* begin processing without buffering the full set.
* `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
* snapshot to alarms whose `alarm_full_reference` starts with the given
* prefix; an empty prefix returns the full set.
* </pre>
*/
public java.util.Iterator<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> queryActiveAlarms(
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request) {
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
getChannel(), getQueryActiveAlarmsMethod(), getCallOptions(), request);
}
} }
/** /**
@@ -608,6 +711,7 @@ public final class MxAccessGatewayGrpc {
private static final int METHODID_STREAM_EVENTS = 3; private static final int METHODID_STREAM_EVENTS = 3;
private static final int METHODID_ACKNOWLEDGE_ALARM = 4; private static final int METHODID_ACKNOWLEDGE_ALARM = 4;
private static final int METHODID_STREAM_ALARMS = 5; private static final int METHODID_STREAM_ALARMS = 5;
private static final int METHODID_QUERY_ACTIVE_ALARMS = 6;
private static final class MethodHandlers<Req, Resp> implements private static final class MethodHandlers<Req, Resp> implements
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>, io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
@@ -650,6 +754,10 @@ public final class MxAccessGatewayGrpc {
serviceImpl.streamAlarms((mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest) request, serviceImpl.streamAlarms((mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest) request,
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>) responseObserver); (io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>) responseObserver);
break; break;
case METHODID_QUERY_ACTIVE_ALARMS:
serviceImpl.queryActiveAlarms((mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest) request,
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>) responseObserver);
break;
default: default:
throw new AssertionError(); throw new AssertionError();
} }
@@ -710,6 +818,13 @@ public final class MxAccessGatewayGrpc {
mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest,
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>( mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>(
service, METHODID_STREAM_ALARMS))) service, METHODID_STREAM_ALARMS)))
.addMethod(
getQueryActiveAlarmsMethod(),
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
new MethodHandlers<
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>(
service, METHODID_QUERY_ACTIVE_ALARMS)))
.build(); .build();
} }
@@ -764,6 +879,7 @@ public final class MxAccessGatewayGrpc {
.addMethod(getStreamEventsMethod()) .addMethod(getStreamEventsMethod())
.addMethod(getAcknowledgeAlarmMethod()) .addMethod(getAcknowledgeAlarmMethod())
.addMethod(getStreamAlarmsMethod()) .addMethod(getStreamAlarmsMethod())
.addMethod(getQueryActiveAlarmsMethod())
.build(); .build();
} }
} }
@@ -10596,8 +10596,8 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
"sitory.v1.DiscoverHierarchyReply\022h\n\021Watc" + "sitory.v1.DiscoverHierarchyReply\022h\n\021Watc" +
"hDeployEvents\022..galaxy_repository.v1.Wat" + "hDeployEvents\022..galaxy_repository.v1.Wat" +
"chDeployEventsRequest\032!.galaxy_repositor" + "chDeployEventsRequest\032!.galaxy_repositor" +
"y.v1.DeployEvent0\001B#\252\002 MxGateway.Contrac" + "y.v1.DeployEvent0\001B-\252\002*ZB.MOM.WW.MxGatew" +
"ts.Proto.Galaxyb\006proto3" "ay.Contracts.Proto.Galaxyb\006proto3"
}; };
descriptor = com.google.protobuf.Descriptors.FileDescriptor descriptor = com.google.protobuf.Descriptors.FileDescriptor
.internalBuildGeneratedFileFrom(descriptorData, .internalBuildGeneratedFileFrom(descriptorData,
File diff suppressed because it is too large Load Diff
@@ -12608,8 +12608,8 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
"CONVERSION_FAILED\020\010\022\"\n\036WORKER_FAULT_CATE" + "CONVERSION_FAILED\020\010\022\"\n\036WORKER_FAULT_CATE" +
"GORY_STA_HUNG\020\t\022(\n$WORKER_FAULT_CATEGORY" + "GORY_STA_HUNG\020\t\022(\n$WORKER_FAULT_CATEGORY" +
"_QUEUE_OVERFLOW\020\n\022*\n&WORKER_FAULT_CATEGO" + "_QUEUE_OVERFLOW\020\n\022*\n&WORKER_FAULT_CATEGO" +
"RY_SHUTDOWN_TIMEOUT\020\013B\034\252\002\031MxGateway.Cont" + "RY_SHUTDOWN_TIMEOUT\020\013B&\252\002#ZB.MOM.WW.MxGa" +
"racts.Protob\006proto3" "teway.Contracts.Protob\006proto3"
}; };
descriptor = com.google.protobuf.Descriptors.FileDescriptor descriptor = com.google.protobuf.Descriptors.FileDescriptor
.internalBuildGeneratedFileFrom(descriptorData, .internalBuildGeneratedFileFrom(descriptorData,
@@ -3,11 +3,11 @@ plugins {
} }
dependencies { dependencies {
implementation project(':mxgateway-client') implementation project(':zb-mom-ww-mxgateway-client')
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}" implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
implementation "info.picocli:picocli:${picocliVersion}" implementation "info.picocli:picocli:${picocliVersion}"
} }
application { application {
mainClass = 'com.dohertylan.mxgateway.cli.MxGatewayCli' mainClass = 'com.zb.mom.ww.mxgateway.cli.MxGatewayCli'
} }
@@ -1,20 +1,21 @@
package com.dohertylan.mxgateway.cli; package com.zb.mom.ww.mxgateway.cli;
import com.dohertylan.mxgateway.client.DeployEventStream; import com.zb.mom.ww.mxgateway.client.DeployEventStream;
import com.dohertylan.mxgateway.client.GalaxyRepositoryClient; import com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient;
import com.dohertylan.mxgateway.client.MxEventStream; import com.zb.mom.ww.mxgateway.client.MxEventStream;
import com.dohertylan.mxgateway.client.MxGatewayAlarmFeedSubscription; import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
import com.dohertylan.mxgateway.client.MxGatewayClient; import com.zb.mom.ww.mxgateway.client.MxGatewayClient;
import com.dohertylan.mxgateway.client.MxGatewayClientOptions; import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
import com.dohertylan.mxgateway.client.MxGatewayClientVersion; import com.zb.mom.ww.mxgateway.client.MxGatewayClientVersion;
import com.dohertylan.mxgateway.client.MxGatewaySecrets; import com.zb.mom.ww.mxgateway.client.MxGatewaySecrets;
import com.dohertylan.mxgateway.client.MxGatewaySession; import com.zb.mom.ww.mxgateway.client.MxGatewaySession;
import com.dohertylan.mxgateway.client.MxValues; import com.zb.mom.ww.mxgateway.client.MxValues;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute; import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject; import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import com.google.protobuf.Message; import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat; import com.google.protobuf.util.JsonFormat;
import io.grpc.stub.StreamObserver;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.PrintWriter; import java.io.PrintWriter;
@@ -32,7 +33,6 @@ import java.util.Optional;
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue; import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import io.grpc.stub.StreamObserver;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply; import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest; import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot; import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
@@ -342,12 +342,9 @@ public final class MxGatewayCli implements Callable<Integer> {
if (json) { if (json) {
out.println(protoJson(event)); out.println(protoJson(event));
} else { } else {
// sequence is a proto uint64 print as unsigned so values
// past 2^63 do not render as negative signed longs. JSON
// path goes through JsonFormat which already does this.
out.printf( out.printf(
"seq=%s observed=%s deployTime=%s objects=%d attributes=%d%n", "seq=%d observed=%s deployTime=%s objects=%d attributes=%d%n",
Long.toUnsignedString(event.getSequence()), event.getSequence(),
formatTimestamp(event.getObservedAt()), formatTimestamp(event.getObservedAt()),
event.getTimeOfLastDeployPresent() event.getTimeOfLastDeployPresent()
? formatTimestamp(event.getTimeOfLastDeploy()) ? formatTimestamp(event.getTimeOfLastDeploy())
@@ -644,7 +641,9 @@ public final class MxGatewayCli implements Callable<Integer> {
@Option(names = "--items", required = true, description = "Comma-separated tag addresses.") @Option(names = "--items", required = true, description = "Comma-separated tag addresses.")
String items; String items;
@Option(names = "--timeout-ms", defaultValue = "0", @Option(
names = "--timeout-ms",
defaultValue = "0",
description = "Per-tag snapshot timeout in milliseconds (0 = worker default).") description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
int timeoutMs; int timeoutMs;
@@ -655,8 +654,8 @@ public final class MxGatewayCli implements Callable<Integer> {
@Override @Override
public Integer call() { public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) { try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
List<BulkReadResult> results = List<BulkReadResult> results = client.session(sessionId)
client.session(sessionId).readBulk(serverHandle, parseStringList(items), timeoutMs); .readBulk(serverHandle, parseStringList(items), Duration.ofMillis(timeoutMs));
writeReadBulkOutput("read-bulk", common, json, results); writeReadBulkOutput("read-bulk", common, json, results);
} }
return 0; return 0;
@@ -694,7 +693,8 @@ public final class MxGatewayCli implements Callable<Integer> {
List<String> valueTexts = parseStringList(values); List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) { if (handles.size() != valueTexts.size()) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")"); "item-handles count (" + handles.size() + ") does not match values count ("
+ valueTexts.size() + ")");
} }
List<WriteBulkEntry> entries = new ArrayList<>(handles.size()); List<WriteBulkEntry> entries = new ArrayList<>(handles.size());
for (int i = 0; i < handles.size(); i++) { for (int i = 0; i < handles.size(); i++) {
@@ -745,7 +745,8 @@ public final class MxGatewayCli implements Callable<Integer> {
List<String> valueTexts = parseStringList(values); List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) { if (handles.size() != valueTexts.size()) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")"); "item-handles count (" + handles.size() + ") does not match values count ("
+ valueTexts.size() + ")");
} }
MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp)); MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
List<Write2BulkEntry> entries = new ArrayList<>(handles.size()); List<Write2BulkEntry> entries = new ArrayList<>(handles.size());
@@ -798,7 +799,8 @@ public final class MxGatewayCli implements Callable<Integer> {
List<String> valueTexts = parseStringList(values); List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) { if (handles.size() != valueTexts.size()) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")"); "item-handles count (" + handles.size() + ") does not match values count ("
+ valueTexts.size() + ")");
} }
List<WriteSecuredBulkEntry> entries = new ArrayList<>(handles.size()); List<WriteSecuredBulkEntry> entries = new ArrayList<>(handles.size());
for (int i = 0; i < handles.size(); i++) { for (int i = 0; i < handles.size(); i++) {
@@ -853,7 +855,8 @@ public final class MxGatewayCli implements Callable<Integer> {
List<String> valueTexts = parseStringList(values); List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) { if (handles.size() != valueTexts.size()) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"item-handles count (" + handles.size() + ") does not match values count (" + valueTexts.size() + ")"); "item-handles count (" + handles.size() + ") does not match values count ("
+ valueTexts.size() + ")");
} }
MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp)); MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
List<WriteSecured2BulkEntry> entries = new ArrayList<>(handles.size()); List<WriteSecured2BulkEntry> entries = new ArrayList<>(handles.size());
@@ -873,224 +876,113 @@ public final class MxGatewayCli implements Callable<Integer> {
} }
} }
/** @Command(
* Cross-language ReadBulk stress benchmark mirrors the .NET / Go / Rust / name = "bench-read-bulk",
* Python implementations so the PS driver collates one JSON schema across description = "Repeatedly invokes ReadBulk for benchmarking; prints aggregate timing.")
* all five clients.
*/
@Command(name = "bench-read-bulk", description = "Cross-language ReadBulk stress benchmark.")
static final class BenchReadBulkCommand extends GatewayCommand { static final class BenchReadBulkCommand extends GatewayCommand {
@Option(names = "--client-name", defaultValue = "mxgw-java-bench") @Option(names = "--session-id", required = true, description = "Gateway session id.")
String clientName; String sessionId;
@Option(names = "--duration-seconds", defaultValue = "30") @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int durationSeconds; int serverHandle;
@Option(names = "--warmup-seconds", defaultValue = "3") @Option(names = "--items", required = true, description = "Comma-separated tag addresses.")
int warmupSeconds; String items;
@Option(names = "--bulk-size", defaultValue = "6") @Option(
int bulkSize; names = "--timeout-ms",
defaultValue = "0",
@Option(names = "--tag-start", defaultValue = "1") description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
int tagStart;
@Option(names = "--tag-prefix", defaultValue = "TestMachine_")
String tagPrefix;
@Option(names = "--tag-attribute", defaultValue = "TestChangingInt")
String tagAttribute;
@Option(names = "--timeout-ms", defaultValue = "1500")
int timeoutMs; int timeoutMs;
@Option(names = "--iterations", defaultValue = "10", description = "Number of ReadBulk calls to perform.")
int iterations;
@Option(
names = "--warmup",
defaultValue = "1",
description = "Number of warmup iterations excluded from timing.")
int warmup;
BenchReadBulkCommand(MxGatewayCliClientFactory clientFactory) { BenchReadBulkCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory); super(clientFactory);
} }
@Override @Override
public Integer call() { public Integer call() {
if (bulkSize < 1) { if (iterations <= 0) {
throw new IllegalArgumentException("bulk-size must be positive"); throw new IllegalArgumentException("--iterations must be positive");
} }
List<String> tags = new ArrayList<>(bulkSize); if (warmup < 0) {
for (int i = 0; i < bulkSize; i++) { throw new IllegalArgumentException("--warmup must be non-negative");
tags.add(String.format("%s%03d.%s", tagPrefix, tagStart + i, tagAttribute));
} }
List<String> tagAddresses = parseStringList(items);
Duration timeout = Duration.ofMillis(timeoutMs);
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) { try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
var openReply = client.openSession(
mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest.newBuilder()
.setClientSessionName(clientName)
.build());
String sessionId = openReply.getSessionId();
MxGatewayCliSession session = client.session(sessionId); MxGatewayCliSession session = client.session(sessionId);
List<Integer> itemHandles = new ArrayList<>(); for (int i = 0; i < warmup; i++) {
long steadyElapsedNanos; session.readBulk(serverHandle, tagAddresses, timeout);
long[] latenciesNanos; }
int latencyCount = 0; long totalNanos = 0L;
long successful = 0; long minNanos = Long.MAX_VALUE;
long failed = 0; long maxNanos = 0L;
long totalResults = 0; int lastResultCount = 0;
long cachedResults = 0; int lastSuccessCount = 0;
int serverHandle = session.register(clientName); int lastCachedCount = 0;
try { for (int i = 0; i < iterations; i++) {
List<SubscribeResult> subscribeResults = session.subscribeBulk(serverHandle, tags); long start = System.nanoTime();
for (SubscribeResult r : subscribeResults) { List<BulkReadResult> results = session.readBulk(serverHandle, tagAddresses, timeout);
if (r.getWasSuccessful()) { long elapsed = System.nanoTime() - start;
itemHandles.add(r.getItemHandle()); totalNanos += elapsed;
minNanos = Math.min(minNanos, elapsed);
maxNanos = Math.max(maxNanos, elapsed);
lastResultCount = results.size();
lastSuccessCount = 0;
lastCachedCount = 0;
for (BulkReadResult result : results) {
if (result.getWasSuccessful()) {
lastSuccessCount++;
}
if (result.getWasCached()) {
lastCachedCount++;
} }
} }
// Warm-up window drives identical calls so JIT / connection
// pool effects are amortised before the measurement window.
long warmupDeadline = System.nanoTime() + warmupSeconds * 1_000_000_000L;
while (System.nanoTime() < warmupDeadline) {
session.readBulk(serverHandle, tags, timeoutMs);
} }
double avgMs = totalNanos / 1_000_000.0 / iterations;
latenciesNanos = new long[Math.max(1024, durationSeconds * 1000)]; double minMs = minNanos / 1_000_000.0;
long steadyStart = System.nanoTime(); double maxMs = maxNanos / 1_000_000.0;
long steadyDeadline = steadyStart + durationSeconds * 1_000_000_000L; PrintWriter out = common.spec.commandLine().getOut();
while (System.nanoTime() < steadyDeadline) { if (json) {
long callStart = System.nanoTime(); Map<String, Object> output = new LinkedHashMap<>();
try { output.put("command", "bench-read-bulk");
List<BulkReadResult> results = session.readBulk(serverHandle, tags, timeoutMs); output.put("options", common.redactedJsonMap());
long elapsed = System.nanoTime() - callStart; output.put("iterations", iterations);
// Only record successful-call latencies including failed-call output.put("warmup", warmup);
// durations would pollute the p50/p95/p99 percentile summary output.put("tagCount", tagAddresses.size());
// (Client.Java-024, mirrors Client.Rust-015). The cross-language output.put("resultCount", lastResultCount);
// bench matrix expects success-only latency histograms. output.put("successCount", lastSuccessCount);
if (latencyCount >= latenciesNanos.length) { output.put("cachedCount", lastCachedCount);
long[] grown = new long[latenciesNanos.length * 2]; output.put("avgMs", avgMs);
System.arraycopy(latenciesNanos, 0, grown, 0, latencyCount); output.put("minMs", minMs);
latenciesNanos = grown; output.put("maxMs", maxMs);
out.println(jsonObject(output));
} else {
out.printf(
"iterations=%d tags=%d avg=%.3fms min=%.3fms max=%.3fms last_results=%d last_success=%d last_cached=%d%n",
iterations,
tagAddresses.size(),
avgMs,
minMs,
maxMs,
lastResultCount,
lastSuccessCount,
lastCachedCount);
} }
latenciesNanos[latencyCount++] = elapsed;
successful++;
for (BulkReadResult r : results) {
totalResults++;
if (r.getWasCached()) {
cachedResults++;
}
}
} catch (Exception ex) {
// Failed-call duration is intentionally NOT recorded into
// the success-latency histogram only count the failure so
// the failedCalls JSON field reflects it.
failed++;
}
}
steadyElapsedNanos = System.nanoTime() - steadyStart;
} finally {
if (!itemHandles.isEmpty()) {
try { session.unsubscribeBulk(serverHandle, itemHandles); } catch (Exception ignored) { }
}
try { client.closeSession(mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest.newBuilder()
.setSessionId(sessionId).build()); } catch (Exception ignored) { }
}
long totalCalls = successful + failed;
double steadyElapsedSeconds = steadyElapsedNanos / 1_000_000_000.0;
double callsPerSecond = steadyElapsedSeconds > 0 ? totalCalls / steadyElapsedSeconds : 0.0;
writeBenchOutput(common, json, tags, clientName, bulkSize, durationSeconds, warmupSeconds,
steadyElapsedNanos, totalCalls, successful, failed, totalResults, cachedResults,
callsPerSecond, latenciesNanos, latencyCount);
} }
return 0; return 0;
} }
} }
private static void writeBenchOutput(
CommonOptions common,
boolean json,
List<String> tags,
String clientName,
int bulkSize,
int durationSeconds,
int warmupSeconds,
long steadyElapsedNanos,
long totalCalls,
long successful,
long failed,
long totalResults,
long cachedResults,
double callsPerSecond,
long[] latenciesNanos,
int latencyCount) {
PrintWriter out = common.spec.commandLine().getOut();
Map<String, Object> latencyMs = percentileSummaryMs(latenciesNanos, latencyCount);
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("language", "java");
output.put("command", "bench-read-bulk");
output.put("endpoint", common.endpoint);
output.put("clientName", clientName);
output.put("bulkSize", bulkSize);
output.put("durationSeconds", durationSeconds);
output.put("warmupSeconds", warmupSeconds);
output.put("durationMs", steadyElapsedNanos / 1_000_000L);
output.put("tags", tags);
output.put("totalCalls", totalCalls);
output.put("successfulCalls", successful);
output.put("failedCalls", failed);
output.put("totalReadResults", totalResults);
output.put("cachedReadResults", cachedResults);
output.put("callsPerSecond", roundTo(callsPerSecond, 2));
output.put("latencyMs", latencyMs);
out.println(jsonObject(output));
return;
}
out.println(callsPerSecond);
}
private static Map<String, Object> percentileSummaryMs(long[] latenciesNanos, int count) {
Map<String, Object> result = new LinkedHashMap<>();
if (count == 0) {
result.put("p50", 0.0);
result.put("p95", 0.0);
result.put("p99", 0.0);
result.put("max", 0.0);
result.put("mean", 0.0);
return result;
}
long[] sorted = new long[count];
System.arraycopy(latenciesNanos, 0, sorted, 0, count);
java.util.Arrays.sort(sorted);
double sumMs = 0.0;
for (int i = 0; i < count; i++) {
sumMs += sorted[i] / 1_000_000.0;
}
result.put("p50", roundTo(percentileMs(sorted, 0.50), 3));
result.put("p95", roundTo(percentileMs(sorted, 0.95), 3));
result.put("p99", roundTo(percentileMs(sorted, 0.99), 3));
result.put("max", roundTo(sorted[count - 1] / 1_000_000.0, 3));
result.put("mean", roundTo(sumMs / count, 3));
return result;
}
private static double percentileMs(long[] sorted, double quantile) {
int n = sorted.length;
if (n == 0) {
return 0.0;
}
if (n == 1) {
return sorted[0] / 1_000_000.0;
}
double rank = quantile * (n - 1);
int lower = (int) Math.floor(rank);
int upper = Math.min(lower + 1, n - 1);
double fraction = rank - lower;
double lowerMs = sorted[lower] / 1_000_000.0;
double upperMs = sorted[upper] / 1_000_000.0;
return lowerMs + (upperMs - lowerMs) * fraction;
}
private static double roundTo(double value, int digits) {
double shift = Math.pow(10, digits);
return Math.round(value * shift) / shift;
}
@Command(name = "write", description = "Invokes MXAccess Write.") @Command(name = "write", description = "Invokes MXAccess Write.")
static final class WriteCommand extends GatewayCommand { static final class WriteCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.") @Option(names = "--session-id", required = true, description = "Gateway session id.")
@@ -1151,13 +1043,7 @@ public final class MxGatewayCli implements Callable<Integer> {
if (json) { if (json) {
client.out().println(protoJson(event)); client.out().println(protoJson(event));
} else { } else {
// worker_sequence is a proto uint64 print as unsigned so client.out().printf("%d %s%n", event.getWorkerSequence(), event.getFamily());
// values past 2^63 do not render as negative signed longs.
// JSON path goes through JsonFormat which already does this.
client.out().printf(
"%s %s%n",
Long.toUnsignedString(event.getWorkerSequence()),
event.getFamily());
} }
count++; count++;
if (limit > 0 && count >= limit) { if (limit > 0 && count >= limit) {
@@ -1349,93 +1235,38 @@ public final class MxGatewayCli implements Callable<Integer> {
@Option(names = "--timeout", defaultValue = "30s", description = "Per-call timeout.") @Option(names = "--timeout", defaultValue = "30s", description = "Per-call timeout.")
String timeout; String timeout;
@Option( private String resolvedApiKey = "";
names = "--shutdown-timeout", private Duration resolvedTimeout = Duration.ofSeconds(30);
description =
"Channel shutdown timeout (e.g. 10s, 500ms). When unset, the library default applies.")
String shutdownTimeout;
/**
* Returns this options object unchanged.
*
* <p>Retained as a no-op for call sites that read more naturally as
* {@code common.resolved()}. Resolution of the API key and timeout is
* computed lazily on demand by {@link #resolvedApiKey()} and
* {@link #resolvedTimeout()}, so {@link #toClientOptions()} and
* {@link #redactedJsonMap()} produce correct output regardless of
* whether this method was ever called.
*
* @return this options object
*/
CommonOptions resolved() { CommonOptions resolved() {
resolvedApiKey = apiKey == null || apiKey.isBlank() ? System.getenv(apiKeyEnv) : apiKey;
if (resolvedApiKey == null) {
resolvedApiKey = "";
}
resolvedTimeout = parseDuration(timeout);
return this; return this;
} }
/**
* Resolves the effective API key: the explicit {@code --api-key} value
* when non-blank, otherwise the value of the {@code --api-key-env}
* environment variable, otherwise an empty string. Computed on each
* call so there is no stale cached state.
*
* @return the resolved API key, never {@code null}
*/
String resolvedApiKey() {
String resolved = apiKey == null || apiKey.isBlank() ? System.getenv(apiKeyEnv) : apiKey;
return resolved == null ? "" : resolved;
}
/**
* Resolves the effective per-call timeout from the {@code --timeout}
* option. Computed on each call so there is no stale cached state.
*
* @return the resolved call timeout
*/
Duration resolvedTimeout() {
return parseDuration(timeout);
}
/**
* Resolves the effective channel-shutdown timeout from the
* {@code --shutdown-timeout} option, or {@code null} when the user did
* not pass one (in which case the {@link MxGatewayClientOptions}
* default applies). Computed on each call so there is no stale cached
* state.
*
* @return the resolved shutdown timeout, or {@code null} when unset
*/
Duration resolvedShutdownTimeout() {
if (shutdownTimeout == null || shutdownTimeout.isBlank()) {
return null;
}
return parseDuration(shutdownTimeout);
}
MxGatewayClientOptions toClientOptions() { MxGatewayClientOptions toClientOptions() {
MxGatewayClientOptions.Builder builder = MxGatewayClientOptions.builder() return MxGatewayClientOptions.builder()
.endpoint(endpoint) .endpoint(endpoint)
.apiKey(resolvedApiKey()) .apiKey(resolvedApiKey)
.plaintext(plaintext) .plaintext(plaintext)
.caCertificatePath(caFile) .caCertificatePath(caFile)
.serverNameOverride(serverNameOverride) .serverNameOverride(serverNameOverride)
.callTimeout(resolvedTimeout()); .callTimeout(resolvedTimeout)
Duration resolvedShutdownTimeout = resolvedShutdownTimeout(); .build();
if (resolvedShutdownTimeout != null) {
builder.shutdownTimeout(resolvedShutdownTimeout);
}
return builder.build();
} }
Map<String, Object> redactedJsonMap() { Map<String, Object> redactedJsonMap() {
Map<String, Object> values = new LinkedHashMap<>(); Map<String, Object> values = new LinkedHashMap<>();
values.put("endpoint", endpoint); values.put("endpoint", endpoint);
values.put("apiKey", MxGatewaySecrets.redactApiKey(resolvedApiKey())); values.put("apiKey", MxGatewaySecrets.redactApiKey(resolvedApiKey));
values.put("apiKeyEnv", apiKeyEnv); values.put("apiKeyEnv", apiKeyEnv);
values.put("plaintext", plaintext); values.put("plaintext", plaintext);
values.put("caFile", caFile == null ? "" : caFile.toString()); values.put("caFile", caFile == null ? "" : caFile.toString());
values.put("serverNameOverride", serverNameOverride); values.put("serverNameOverride", serverNameOverride);
values.put("timeout", timeout); values.put("timeout", timeout);
Duration resolvedShutdownTimeout = resolvedShutdownTimeout();
values.put("shutdownTimeout", resolvedShutdownTimeout == null ? "" : resolvedShutdownTimeout.toString());
return values; return values;
} }
} }
@@ -1481,7 +1312,7 @@ public final class MxGatewayCli implements Callable<Integer> {
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles); List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs); List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout);
List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries); List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries);
@@ -1594,8 +1425,8 @@ public final class MxGatewayCli implements Callable<Integer> {
} }
@Override @Override
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs) { public List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout) {
return session.readBulk(serverHandle, items, timeoutMs); return session.readBulk(serverHandle, items, timeout);
} }
@Override @Override
@@ -1,16 +1,17 @@
package com.dohertylan.mxgateway.cli; package com.zb.mom.ww.mxgateway.cli;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
import io.grpc.stub.StreamObserver;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.InputStream; import java.io.InputStream;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.io.StringWriter; import java.io.StringWriter;
import com.dohertylan.mxgateway.client.MxGatewayAlarmFeedSubscription;
import io.grpc.stub.StreamObserver;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply; import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
@@ -32,10 +33,10 @@ import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply; import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest; import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus; import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode; import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply; import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
import mxaccess_gateway.v1.MxaccessGateway.SessionState; import mxaccess_gateway.v1.MxaccessGateway.SessionState;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult; import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry; import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry; import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
@@ -81,10 +82,8 @@ final class MxGatewayCliTests {
assertEquals(0, run.exitCode()); assertEquals(0, run.exitCode());
assertTrue(run.output().contains("\"command\":\"open-session\"")); assertTrue(run.output().contains("\"command\":\"open-session\""));
assertTrue(run.output().contains("\"sessionId\":\"session-cli\"")); assertTrue(run.output().contains("\"sessionId\":\"session-cli\""));
// Only the non-secret mxgw_<key-id>_ prefix survives; the secret is fully masked. assertTrue(run.output().contains("mxgw***********cret"));
assertTrue(run.output().contains("mxgw_visible_***"));
assertFalse(run.output().contains("visible_secret")); assertFalse(run.output().contains("visible_secret"));
assertFalse(run.output().contains("cret"));
} }
@Test @Test
@@ -143,40 +142,6 @@ final class MxGatewayCliTests {
assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_002.TestChangingInt\"")); assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_002.TestChangingInt\""));
} }
@Test
void deployEventSequenceRendersAsUnsignedForHighUint64() {
// Client.Java-020 regression: galaxy-watch text output now uses
// Long.toUnsignedString to format the proto uint64 sequence field, so
// values past 2^63 render as positive decimal strings instead of the
// negative signed-long interpretation the old "%d" produced.
long highUnsigned = -1L; // bit-pattern for 2^64 - 1, i.e. 18446744073709551615 unsigned
String text = String.format(
"seq=%s observed=%s deployTime=%s objects=%d attributes=%d",
Long.toUnsignedString(highUnsigned),
"2026-05-20T00:00:00Z",
"(none)",
0,
0);
assertTrue(text.contains("seq=18446744073709551615"), "expected unsigned rendering, got: " + text);
assertFalse(text.contains("seq=-1"), "must not render as signed -1");
}
@Test
void streamEventsWorkerSequenceRendersAsUnsignedForHighUint64() {
// Client.Java-023 regression: stream-events text output now uses
// Long.toUnsignedString to format the proto uint64 worker_sequence
// field, mirroring the Client.Java-020 fix for DeployEvent.sequence.
long highUnsigned = -1L; // bit-pattern for 2^64 - 1, i.e. 18446744073709551615 unsigned
String text = String.format(
"%s %s",
Long.toUnsignedString(highUnsigned),
"MX_EVENT_FAMILY_DATA_CHANGE");
assertTrue(text.startsWith("18446744073709551615 "), "expected unsigned rendering, got: " + text);
assertFalse(text.startsWith("-1 "), "must not render as signed -1");
}
@Test @Test
void unsubscribeBulkCommandPrintsResults() { void unsubscribeBulkCommandPrintsResults() {
CliRun run = execute( CliRun run = execute(
@@ -196,209 +161,6 @@ final class MxGatewayCliTests {
assertTrue(run.output().contains("\"wasSuccessful\":true")); assertTrue(run.output().contains("\"wasSuccessful\":true"));
} }
// ---- Client.Java-026: CLI-level coverage for bulk subcommands ----
@Test
void readBulkCommandForwardsTimeoutAndPrintsResults() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"read-bulk",
"--session-id",
"session-cli",
"--server-handle",
"42",
"--items",
"TestMachine_001.TestChangingInt,TestMachine_002.TestChangingInt",
"--timeout-ms",
"750",
"--json");
assertEquals(0, run.exitCode());
assertEquals(750, factory.client.session.lastReadBulkTimeoutMs);
assertEquals(2, factory.client.session.lastReadBulkItems.size());
assertTrue(run.output().contains("\"command\":\"read-bulk\""));
assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_001.TestChangingInt\""));
assertTrue(run.output().contains("\"itemHandle\":200"));
assertTrue(run.output().contains("\"wasCached\":true"));
assertTrue(run.output().contains("\"quality\":192"));
}
@Test
void writeBulkCommandParsesTypedValuesAndPrintsResults() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"write-bulk",
"--session-id",
"session-cli",
"--server-handle",
"42",
"--item-handles",
"100,101",
"--type",
"int32",
"--values",
"111,222",
"--user-id",
"5",
"--json");
assertEquals(0, run.exitCode());
assertEquals(2, factory.client.session.lastWriteBulkEntries.size());
assertEquals(111, factory.client.session.lastWriteBulkEntries.get(0).getValue().getInt32Value());
assertEquals(222, factory.client.session.lastWriteBulkEntries.get(1).getValue().getInt32Value());
assertEquals(5, factory.client.session.lastWriteBulkEntries.get(0).getUserId());
assertTrue(run.output().contains("\"command\":\"write-bulk\""));
assertTrue(run.output().contains("\"itemHandle\":100"));
assertTrue(run.output().contains("\"wasSuccessful\":true"));
}
@Test
void write2BulkCommandForwardsTimestampAndPrintsResults() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"write2-bulk",
"--session-id",
"session-cli",
"--server-handle",
"42",
"--item-handles",
"100",
"--type",
"string",
"--values",
"hello",
"--timestamp",
"2026-05-20T00:00:00Z",
"--json");
assertEquals(0, run.exitCode());
assertEquals(1, factory.client.session.lastWrite2BulkEntries.size());
assertEquals(
"hello",
factory.client.session.lastWrite2BulkEntries.get(0).getValue().getStringValue());
assertTrue(
factory.client.session.lastWrite2BulkEntries.get(0).hasTimestampValue(),
"expected timestampValue to be forwarded");
assertTrue(run.output().contains("\"command\":\"write2-bulk\""));
assertTrue(run.output().contains("\"itemHandle\":100"));
assertTrue(run.output().contains("\"wasSuccessful\":true"));
}
@Test
void writeSecuredBulkCommandForwardsUserIdsAndPrintsResults() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"write-secured-bulk",
"--session-id",
"session-cli",
"--server-handle",
"42",
"--item-handles",
"100",
"--type",
"int32",
"--values",
"9",
"--current-user-id",
"7",
"--verifier-user-id",
"8",
"--json");
assertEquals(0, run.exitCode());
assertEquals(1, factory.client.session.lastWriteSecuredBulkEntries.size());
assertEquals(7, factory.client.session.lastWriteSecuredBulkEntries.get(0).getCurrentUserId());
assertEquals(8, factory.client.session.lastWriteSecuredBulkEntries.get(0).getVerifierUserId());
assertEquals(9, factory.client.session.lastWriteSecuredBulkEntries.get(0).getValue().getInt32Value());
assertTrue(run.output().contains("\"command\":\"write-secured-bulk\""));
assertTrue(run.output().contains("\"wasSuccessful\":true"));
}
@Test
void writeSecured2BulkCommandForwardsTimestampAndUserIdsAndPrintsResults() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"write-secured2-bulk",
"--session-id",
"session-cli",
"--server-handle",
"42",
"--item-handles",
"100",
"--type",
"string",
"--values",
"value",
"--timestamp",
"2026-05-20T00:00:00Z",
"--current-user-id",
"7",
"--verifier-user-id",
"8",
"--json");
assertEquals(0, run.exitCode());
assertEquals(1, factory.client.session.lastWriteSecured2BulkEntries.size());
assertEquals(7, factory.client.session.lastWriteSecured2BulkEntries.get(0).getCurrentUserId());
assertEquals(8, factory.client.session.lastWriteSecured2BulkEntries.get(0).getVerifierUserId());
assertTrue(
factory.client.session.lastWriteSecured2BulkEntries.get(0).hasTimestampValue(),
"expected timestampValue to be forwarded");
assertTrue(run.output().contains("\"command\":\"write-secured2-bulk\""));
assertTrue(run.output().contains("\"wasSuccessful\":true"));
}
@Test
void benchReadBulkCommandEmitsJsonSchemaKeys() {
// Short bench window (1 s steady, 0 s warmup) keeps the test fast; we assert
// the JSON schema rather than numeric values so the cross-language matrix
// (.NET / Go / Rust / Python) and the Java path agree on the output shape.
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"bench-read-bulk",
"--duration-seconds",
"1",
"--warmup-seconds",
"0",
"--bulk-size",
"2",
"--tag-start",
"1",
"--tag-prefix",
"TestMachine_",
"--tag-attribute",
"TestChangingInt",
"--timeout-ms",
"100",
"--json");
assertEquals(0, run.exitCode());
String output = run.output();
assertTrue(output.contains("\"language\":\"java\""), output);
assertTrue(output.contains("\"command\":\"bench-read-bulk\""), output);
assertTrue(output.contains("\"bulkSize\":2"), output);
assertTrue(output.contains("\"durationSeconds\":1"), output);
assertTrue(output.contains("\"warmupSeconds\":0"), output);
assertTrue(output.contains("\"totalCalls\":"), output);
assertTrue(output.contains("\"successfulCalls\":"), output);
assertTrue(output.contains("\"failedCalls\":"), output);
assertTrue(output.contains("\"callsPerSecond\":"), output);
assertTrue(output.contains("\"latencyMs\":"), output);
assertTrue(output.contains("\"p50\":"), output);
assertTrue(output.contains("\"p95\":"), output);
assertTrue(output.contains("\"p99\":"), output);
assertTrue(output.contains("\"tags\":"), output);
// Bench tag synthesis: TestMachine_001.TestChangingInt, TestMachine_002.TestChangingInt.
assertTrue(output.contains("TestMachine_001.TestChangingInt"), output);
assertTrue(output.contains("TestMachine_002.TestChangingInt"), output);
}
// ---- stream-alarms / acknowledge-alarm subcommands ---- // ---- stream-alarms / acknowledge-alarm subcommands ----
@Test @Test
@@ -463,73 +225,30 @@ final class MxGatewayCliTests {
assertTrue(run.errors().contains("--reference"), run.errors()); assertTrue(run.errors().contains("--reference"), run.errors());
} }
// ---- Client.Java-027: batch subcommand ----
@Test @Test
void batchCommandExecutesTwoCommandsAndEmitsEorAfterEach() { void batchCommandExecutesVersionAndEmitsEorMarker() {
String stdin = "version --json\nversion --json\n"; CliRun run = executeBatch(new FakeClientFactory(), "version --json\n");
CliRun run = executeBatch(new FakeClientFactory(), stdin);
assertEquals(0, run.exitCode()); assertEquals(0, run.exitCode());
String out = run.output(); String out = run.output();
// Two EOR sentinels one per input line.
int firstEor = out.indexOf(MxGatewayCli.BATCH_EOR);
int lastEor = out.lastIndexOf(MxGatewayCli.BATCH_EOR);
assertTrue(firstEor >= 0, "expected at least one EOR sentinel");
assertTrue(lastEor > firstEor, "expected two distinct EOR sentinels");
// Both results contain version JSON.
assertTrue(out.contains("\"clientVersion\""), out); assertTrue(out.contains("\"clientVersion\""), out);
assertTrue(out.contains(MxGatewayCli.BATCH_EOR), out);
} }
@Test @Test
void batchCommandEmitsEorOnFailedCommand() { void batchCommandEmitsEorAfterFailedCommandAndContinues() {
// "open-session" without --endpoint / --api-key-env will fail against // An unknown subcommand causes a picocli parse error (non-zero exit).
// the FakeClientFactory (missing required option --session-id for // The loop must still emit BATCH_EOR for the failure and continue
// close-session, for example). Use an unknown subcommand to provoke a // processing the subsequent valid command.
// picocli parse error which produces a non-zero exit code without CliRun run = executeBatch(new FakeClientFactory(), "no-such-subcommand\nversion --json\n");
// hitting the gateway.
String stdin = "no-such-subcommand\nversion --json\n";
CliRun run = executeBatch(new FakeClientFactory(), stdin);
assertEquals(0, run.exitCode());
String out = run.output();
// Two EOR sentinels even though the first command failed.
int firstEor = out.indexOf(MxGatewayCli.BATCH_EOR);
int lastEor = out.lastIndexOf(MxGatewayCli.BATCH_EOR);
assertTrue(firstEor >= 0, "expected EOR after failed command");
assertTrue(lastEor > firstEor, "expected EOR after second (successful) command");
// The second command's result is present.
assertTrue(out.contains("\"clientVersion\""), out);
}
@Test
void batchCommandExitsZeroOnEmptyLine() {
// An empty line signals EOF-equivalent; loop exits immediately.
CliRun run = executeBatch(new FakeClientFactory(), "\n");
assertEquals(0, run.exitCode());
}
@Test
void batchCommandExitsZeroOnActualEof() {
CliRun run = executeBatch(new FakeClientFactory(), "");
assertEquals(0, run.exitCode());
}
@Test
void batchCommandDoesNotTerminateAfterFailedCommand() {
// Three lines: good, bad, good all three EORs must appear and the
// third command must produce its output.
String stdin = "version --json\nno-such-subcommand\nversion --json\n";
CliRun run = executeBatch(new FakeClientFactory(), stdin);
assertEquals(0, run.exitCode()); assertEquals(0, run.exitCode());
String out = run.output(); String out = run.output();
long eorCount = out.lines() long eorCount = out.lines()
.filter(l -> l.equals(MxGatewayCli.BATCH_EOR)) .filter(l -> l.equals(MxGatewayCli.BATCH_EOR))
.count(); .count();
assertEquals(3, eorCount, "expected exactly 3 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out); assertEquals(2, eorCount, "expected exactly 2 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out);
assertTrue(out.contains("\"clientVersion\""), out);
} }
/** /**
@@ -737,21 +456,8 @@ final class MxGatewayCliTests {
return results; return results;
} }
// Recorded so tests can assert the CLI forwarded the parsed options through to
// the session interface. The bulk subcommands return at least one result so the
// JSON output assertions exercise the *Map serialisers in MxGatewayCli.
private int lastReadBulkTimeoutMs;
private List<String> lastReadBulkItems = new ArrayList<>();
private List<WriteBulkEntry> lastWriteBulkEntries = new ArrayList<>();
private List<Write2BulkEntry> lastWrite2BulkEntries = new ArrayList<>();
private List<WriteSecuredBulkEntry> lastWriteSecuredBulkEntries = new ArrayList<>();
private List<WriteSecured2BulkEntry> lastWriteSecured2BulkEntries = new ArrayList<>();
@Override @Override
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs) { public List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout) {
lastReadBulkTimeoutMs = timeoutMs;
lastReadBulkItems = items;
List<BulkReadResult> results = new ArrayList<>(); List<BulkReadResult> results = new ArrayList<>();
for (int index = 0; index < items.size(); index++) { for (int index = 0; index < items.size(); index++) {
results.add(BulkReadResult.newBuilder() results.add(BulkReadResult.newBuilder()
@@ -759,8 +465,7 @@ final class MxGatewayCliTests {
.setTagAddress(items.get(index)) .setTagAddress(items.get(index))
.setItemHandle(200 + index) .setItemHandle(200 + index)
.setWasSuccessful(true) .setWasSuccessful(true)
.setWasCached(index % 2 == 0) .setWasCached(true)
.setQuality(192)
.build()); .build());
} }
return results; return results;
@@ -768,7 +473,6 @@ final class MxGatewayCliTests {
@Override @Override
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) { public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
lastWriteBulkEntries = entries;
List<BulkWriteResult> results = new ArrayList<>(); List<BulkWriteResult> results = new ArrayList<>();
for (WriteBulkEntry entry : entries) { for (WriteBulkEntry entry : entries) {
results.add(BulkWriteResult.newBuilder() results.add(BulkWriteResult.newBuilder()
@@ -782,7 +486,6 @@ final class MxGatewayCliTests {
@Override @Override
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) { public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
lastWrite2BulkEntries = entries;
List<BulkWriteResult> results = new ArrayList<>(); List<BulkWriteResult> results = new ArrayList<>();
for (Write2BulkEntry entry : entries) { for (Write2BulkEntry entry : entries) {
results.add(BulkWriteResult.newBuilder() results.add(BulkWriteResult.newBuilder()
@@ -796,7 +499,6 @@ final class MxGatewayCliTests {
@Override @Override
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) { public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
lastWriteSecuredBulkEntries = entries;
List<BulkWriteResult> results = new ArrayList<>(); List<BulkWriteResult> results = new ArrayList<>();
for (WriteSecuredBulkEntry entry : entries) { for (WriteSecuredBulkEntry entry : entries) {
results.add(BulkWriteResult.newBuilder() results.add(BulkWriteResult.newBuilder()
@@ -810,7 +512,6 @@ final class MxGatewayCliTests {
@Override @Override
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) { public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
lastWriteSecured2BulkEntries = entries;
List<BulkWriteResult> results = new ArrayList<>(); List<BulkWriteResult> results = new ArrayList<>();
for (WriteSecured2BulkEntry entry : entries) { for (WriteSecured2BulkEntry entry : entries) {
results.add(BulkWriteResult.newBuilder() results.add(BulkWriteResult.newBuilder()
@@ -823,7 +524,7 @@ final class MxGatewayCliTests {
} }
@Override @Override
public com.dohertylan.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) { public com.zb.mom.ww.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
throw new UnsupportedOperationException("stream-events is covered by client tests"); throw new UnsupportedOperationException("stream-events is covered by client tests");
} }
} }
@@ -22,7 +22,7 @@ dependencies {
sourceSets { sourceSets {
main { main {
proto { proto {
srcDir rootProject.file('../../src/MxGateway.Contracts/Protos') srcDir rootProject.file('../../src/ZB.MOM.WW.MxGateway.Contracts/Protos')
include 'mxaccess_gateway.proto' include 'mxaccess_gateway.proto'
include 'mxaccess_worker.proto' include 'mxaccess_worker.proto'
include 'galaxy_repository.proto' include 'galaxy_repository.proto'
@@ -1,4 +1,4 @@
package com.dohertylan.mxgateway.client; package com.zb.mom.ww.mxgateway.client;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest; import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
@@ -11,29 +11,20 @@ import java.util.NoSuchElementException;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue; import java.util.concurrent.BlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming * Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming
* RPC. Mirrors {@link MxEventStream}: events arrive on a background gRPC thread * RPC. Mirrors {@link MxEventStream}: events arrive on a background gRPC thread
* and are buffered in a bounded blocking queue; the iterator drains them. * and are buffered in a bounded blocking queue; the iterator drains them.
* Closing the stream cancels the underlying gRPC call. * Closing the stream cancels the underlying gRPC call.
*
* <p><strong>Threading:</strong> the iterator methods ({@link #hasNext()} and
* {@link #next()}) are <em>not</em> thread-safe and must be driven by a single
* consumer thread. {@link #close()} may be called from any thread. Terminal
* state transitions (queue overflow, server completion, and {@code close()})
* are serialised so that the first terminal condition wins deterministically:
* once an overflow exception has been observed it is never silently replaced
* by an end-of-stream marker.
*/ */
public final class DeployEventStream implements Iterator<DeployEvent>, AutoCloseable { public final class DeployEventStream implements Iterator<DeployEvent>, AutoCloseable {
private static final Object END = new Object(); private static final Object END = new Object();
private final BlockingQueue<Object> queue; private final BlockingQueue<Object> queue;
private final Object terminalLock = new Object(); private final AtomicBoolean closed = new AtomicBoolean();
private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream; private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream;
private volatile boolean closed;
private boolean terminated;
private Object next; private Object next;
DeployEventStream(int capacity) { DeployEventStream(int capacity) {
@@ -45,7 +36,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
@Override @Override
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> requestStream) { public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> requestStream) {
DeployEventStream.this.requestStream = requestStream; DeployEventStream.this.requestStream = requestStream;
if (closed) { if (closed.get()) {
requestStream.cancel("client cancelled deploy event stream", null); requestStream.cancel("client cancelled deploy event stream", null);
} }
} }
@@ -57,7 +48,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
@Override @Override
public void onError(Throwable error) { public void onError(Throwable error) {
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed) { if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed.get()) {
offer(END); offer(END);
return; return;
} }
@@ -103,12 +94,12 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
@Override @Override
public void close() { public void close() {
closed = true; closed.set(true);
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream; ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
if (stream != null) { if (stream != null) {
stream.cancel("client cancelled deploy event stream", null); stream.cancel("client cancelled deploy event stream", null);
} }
terminate(null); offer(END);
} }
private Object take() { private Object take() {
@@ -126,7 +117,10 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
private void offer(Object value) { private void offer(Object value) {
Objects.requireNonNull(value, "value"); Objects.requireNonNull(value, "value");
if (value == END) { if (value == END) {
terminate(null); if (!queue.offer(value)) {
queue.clear();
queue.offer(value);
}
return; return;
} }
if (!queue.offer(value)) { if (!queue.offer(value)) {
@@ -134,40 +128,9 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
if (stream != null) { if (stream != null) {
stream.cancel("client deploy event stream queue overflowed", null); stream.cancel("client deploy event stream queue overflowed", null);
} }
terminate(new MxGatewayException("galaxy watch deploy events queue overflowed"));
}
}
/**
* Drives the single terminal transition. The first caller wins: a later
* end-of-stream or {@code close()} cannot overwrite or discard an overflow
* exception that has already been published to the consumer. Mirrors the
* {@link MxEventStream#terminate} contract see Client.Java-002 for the
* race this guards against.
*
* @param fault the fault to surface to the consumer, or {@code null} for a
* clean end-of-stream
*/
private void terminate(MxGatewayException fault) {
synchronized (terminalLock) {
if (terminated) {
return;
}
terminated = true;
if (fault != null) {
// Make room for the fault marker; the consumer only needs the
// terminal signal, queued data events are no longer relevant.
queue.clear();
queue.offer(fault);
queue.offer(END);
return;
}
// Clean end-of-stream: ensure the END marker is delivered even when
// the queue is currently full of undrained data events.
if (!queue.offer(END)) {
queue.clear(); queue.clear();
queue.offer(new MxGatewayException("galaxy watch deploy events queue overflowed"));
queue.offer(END); queue.offer(END);
} }
} }
} }
}
@@ -1,4 +1,4 @@
package com.dohertylan.mxgateway.client; package com.zb.mom.ww.mxgateway.client;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest; import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
@@ -1,5 +1,8 @@
package com.dohertylan.mxgateway.client; package com.zb.mom.ww.mxgateway.client;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;
import galaxy_repository.v1.GalaxyRepositoryGrpc; import galaxy_repository.v1.GalaxyRepositoryGrpc;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
@@ -14,6 +17,8 @@ import com.google.protobuf.Timestamp;
import io.grpc.Channel; import io.grpc.Channel;
import io.grpc.ClientInterceptors; import io.grpc.ClientInterceptors;
import io.grpc.ManagedChannel; import io.grpc.ManagedChannel;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import io.grpc.stub.StreamObserver; import io.grpc.stub.StreamObserver;
import java.time.Instant; import java.time.Instant;
import java.util.Iterator; import java.util.Iterator;
@@ -21,7 +26,8 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLException;
/** /**
* Thin wrapper around the generated {@link GalaxyRepositoryGrpc} stubs that * Thin wrapper around the generated {@link GalaxyRepositoryGrpc} stubs that
@@ -72,8 +78,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* @return a connected client * @return a connected client
*/ */
public static GalaxyRepositoryClient connect(MxGatewayClientOptions options) { public static GalaxyRepositoryClient connect(MxGatewayClientOptions options) {
return new GalaxyRepositoryClient( return new GalaxyRepositoryClient(createChannel(options), options);
MxGatewayChannels.createChannel(options, "failed to configure galaxy repository TLS"), options);
} }
/** /**
@@ -82,7 +87,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* @return the blocking stub * @return the blocking stub
*/ */
public GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub rawBlockingStub() { public GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub rawBlockingStub() {
return MxGatewayChannels.withDeadline(blockingStub, options); return withDeadline(blockingStub);
} }
/** /**
@@ -91,7 +96,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* @return the future stub * @return the future stub
*/ */
public GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub rawFutureStub() { public GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub rawFutureStub() {
return MxGatewayChannels.withDeadline(futureStub, options); return withDeadline(futureStub);
} }
/** /**
@@ -128,14 +133,8 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* exceptionally with {@link MxGatewayException} on failure * exceptionally with {@link MxGatewayException} on failure
*/ */
public CompletableFuture<Boolean> testConnectionAsync() { public CompletableFuture<Boolean> testConnectionAsync() {
// Apply the projection inside toCompletable rather than via .thenApply return toCompletable(rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()))
// so the user-visible future is the same future cancellation is bound .thenApply(TestConnectionReply::getOk);
// to; a downstream .thenApply stage would not forward cancel() to the
// source RPC.
return MxGatewayChannels.toCompletable(
rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()),
"galaxy test connection",
TestConnectionReply::getOk);
} }
/** /**
@@ -166,10 +165,8 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* completed exceptionally with {@link MxGatewayException} on failure * completed exceptionally with {@link MxGatewayException} on failure
*/ */
public CompletableFuture<Optional<Instant>> getLastDeployTimeAsync() { public CompletableFuture<Optional<Instant>> getLastDeployTimeAsync() {
return MxGatewayChannels.toCompletable( return toCompletable(rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()))
rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()), .thenApply(GalaxyRepositoryClient::mapDeployTime);
"galaxy get last deploy time",
GalaxyRepositoryClient::mapDeployTime);
} }
/** /**
@@ -213,33 +210,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* exceptionally with {@link MxGatewayException} on failure * exceptionally with {@link MxGatewayException} on failure
*/ */
public CompletableFuture<List<GalaxyObject>> discoverHierarchyAsync() { public CompletableFuture<List<GalaxyObject>> discoverHierarchyAsync() {
// The recursive page chain produces a fresh in-flight RPC per page. return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>());
// Track the current in-flight stage in an AtomicReference and return a
// user-facing future whose cancel() forwards to that current stage
// otherwise cancelling the chained CompletableFuture would not abort
// the in-flight gRPC call. Without this, .thenCompose creates new
// stages whose cancel() does not propagate upstream.
AtomicReference<CompletableFuture<?>> currentStage = new AtomicReference<>();
CompletableFuture<List<GalaxyObject>> userFuture = new CompletableFuture<>() {
@Override
public boolean cancel(boolean mayInterruptIfRunning) {
boolean cancelled = super.cancel(mayInterruptIfRunning);
CompletableFuture<?> stage = currentStage.get();
if (stage != null) {
stage.cancel(mayInterruptIfRunning);
}
return cancelled;
}
};
discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>(), currentStage)
.whenComplete((result, error) -> {
if (error != null) {
userFuture.completeExceptionally(error);
} else {
userFuture.complete(result);
}
});
return userFuture;
} }
/** /**
@@ -253,8 +224,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
*/ */
public DeployEventStream watchDeployEvents(Instant lastSeenDeployTime) { public DeployEventStream watchDeployEvents(Instant lastSeenDeployTime) {
DeployEventStream stream = new DeployEventStream(16); DeployEventStream stream = new DeployEventStream(16);
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options) withStreamDeadline(rawAsyncStub()).watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
return stream; return stream;
} }
@@ -283,7 +253,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
Instant lastSeenDeployTime, StreamObserver<DeployEvent> observer) { Instant lastSeenDeployTime, StreamObserver<DeployEvent> observer) {
Objects.requireNonNull(observer, "observer"); Objects.requireNonNull(observer, "observer");
DeployEventSubscription subscription = new DeployEventSubscription(); DeployEventSubscription subscription = new DeployEventSubscription();
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options) withStreamDeadline(rawAsyncStub())
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), subscription.wrap(observer)); .watchDeployEvents(buildWatchRequest(lastSeenDeployTime), subscription.wrap(observer));
return subscription; return subscription;
} }
@@ -299,35 +269,34 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
return builder.build(); return builder.build();
} }
/** private <T extends io.grpc.stub.AbstractStub<T>> T withStreamDeadline(T stub) {
* Shuts the owned channel down and awaits termination so try-with-resources if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
* callers do not leave in-flight calls or Netty event-loop threads running return stub;
* after the block exits. }
* return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
* <p>Waits up to {@link MxGatewayClientOptions#shutdownTimeout()} for }
* graceful termination and forcibly shuts the channel down on timeout. If
* the calling thread is interrupted while waiting, the channel is forcibly
* shut down and the thread's interrupt flag is restored. No-op for clients
* that do not own their channel. For an explicitly checked, blocking-aware
* shutdown call {@link #closeAndAwaitTermination()}. Delegates to the
* shared {@link MxGatewayChannels#shutdown} so behavior stays in lockstep
* with {@link MxGatewayClient}.
*/
@Override @Override
public void close() { public void close() {
MxGatewayChannels.shutdown(ownedChannel, options); if (ownedChannel != null) {
ownedChannel.shutdown();
}
} }
/** /**
* Shuts the owned channel down and waits up to * Shuts the owned channel down and waits up to the configured connect
* {@link MxGatewayClientOptions#shutdownTimeout()} for termination, * timeout for termination, forcibly shutting it down on timeout. No-op
* forcibly shutting it down on timeout. No-op for clients that do not own * for clients that do not own their channel.
* their channel.
* *
* @throws InterruptedException if the calling thread is interrupted while waiting * @throws InterruptedException if the calling thread is interrupted while waiting
*/ */
public void closeAndAwaitTermination() throws InterruptedException { public void closeAndAwaitTermination() throws InterruptedException {
MxGatewayChannels.shutdownAndAwaitTermination(ownedChannel, options); if (ownedChannel != null) {
ownedChannel.shutdown();
if (!ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
ownedChannel.shutdownNow();
}
}
} }
private static Optional<Instant> mapDeployTime(GetLastDeployTimeReply reply) { private static Optional<Instant> mapDeployTime(GetLastDeployTimeReply reply) {
@@ -338,22 +307,47 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
return Optional.of(Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos())); return Optional.of(Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()));
} }
private static ManagedChannel createChannel(MxGatewayClientOptions options) {
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
.maxInboundMessageSize(options.maxGrpcMessageBytes());
if (!options.connectTimeout().isNegative()) {
builder.withOption(
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
Math.toIntExact(options.connectTimeout().toMillis()));
}
if (options.plaintext()) {
builder.usePlaintext();
} else if (options.caCertificatePath() != null) {
try {
builder.sslContext(GrpcSslContexts.forClient()
.trustManager(options.caCertificatePath().toFile())
.build());
} catch (SSLException error) {
throw new MxGatewayException("failed to configure galaxy repository TLS", error);
}
} else {
builder.useTransportSecurity();
}
if (!options.serverNameOverride().isBlank()) {
builder.overrideAuthority(options.serverNameOverride());
}
return builder.build();
}
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
if (options.callTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
private CompletableFuture<List<GalaxyObject>> discoverHierarchyPageAsync( private CompletableFuture<List<GalaxyObject>> discoverHierarchyPageAsync(
String pageToken, String pageToken, java.util.ArrayList<GalaxyObject> objects, java.util.HashSet<String> seenPageTokens) {
java.util.ArrayList<GalaxyObject> objects,
java.util.HashSet<String> seenPageTokens,
AtomicReference<CompletableFuture<?>> currentStage) {
DiscoverHierarchyRequest request = DiscoverHierarchyRequest.newBuilder() DiscoverHierarchyRequest request = DiscoverHierarchyRequest.newBuilder()
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE) .setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
.setPageToken(pageToken) .setPageToken(pageToken)
.build(); .build();
CompletableFuture<DiscoverHierarchyReply> pageFuture = MxGatewayChannels.toCompletable( return toCompletable(rawFutureStub().discoverHierarchy(request)).thenCompose(reply -> {
rawFutureStub().discoverHierarchy(request), "galaxy discover hierarchy");
// Publish the in-flight page future so a user cancellation can abort
// the current outstanding RPC (the recursion replaces this reference
// before each subsequent page).
currentStage.set(pageFuture);
return pageFuture.thenCompose(reply -> {
objects.addAll(reply.getObjectsList()); objects.addAll(reply.getObjectsList());
if (reply.getNextPageToken().isBlank()) { if (reply.getNextPageToken().isBlank()) {
return CompletableFuture.completedFuture(objects); return CompletableFuture.completedFuture(objects);
@@ -361,11 +355,38 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
if (!seenPageTokens.add(reply.getNextPageToken())) { if (!seenPageTokens.add(reply.getNextPageToken())) {
CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>(); CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>();
failed.completeExceptionally(new MxGatewayException( failed.completeExceptionally(new MxGatewayException(
"galaxy discover hierarchy returned repeated page token: " "galaxy discover hierarchy returned repeated page token: " + reply.getNextPageToken()));
+ reply.getNextPageToken()));
return failed; return failed;
} }
return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens, currentStage); return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens);
}); });
} }
private static <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
CompletableFuture<T> target = new CompletableFuture<>();
Futures.addCallback(
source,
new FutureCallback<>() {
@Override
public void onSuccess(T result) {
target.complete(result);
}
@Override
public void onFailure(Throwable error) {
if (error instanceof RuntimeException runtimeException) {
target.completeExceptionally(MxGatewayErrors.fromGrpc("galaxy async call", runtimeException));
return;
}
target.completeExceptionally(error);
}
},
MoreExecutors.directExecutor());
target.whenComplete((ignoredResult, ignoredError) -> {
if (target.isCancelled()) {
source.cancel(true);
}
});
return target;
}
} }
@@ -1,4 +1,4 @@
package com.dohertylan.mxgateway.client; package com.zb.mom.ww.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply; import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus; import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
@@ -1,4 +1,4 @@
package com.dohertylan.mxgateway.client; package com.zb.mom.ww.mxgateway.client;
import io.grpc.Status; import io.grpc.Status;
import io.grpc.StatusRuntimeException; import io.grpc.StatusRuntimeException;
@@ -21,38 +21,13 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
* stream cancels the underlying gRPC call. If the queue overflows the call is * stream cancels the underlying gRPC call. If the queue overflows the call is
* cancelled and a follow-up call to {@link #next()} throws * cancelled and a follow-up call to {@link #next()} throws
* {@link MxGatewayException}. * {@link MxGatewayException}.
*
* <p><strong>Backpressure (fail-fast):</strong> this adaptor relies on gRPC's
* default auto-inbound flow control the async stub auto-requests messages, so
* the gateway can push events faster than the consumer drains the bounded
* 1024-element buffer (the buffer capacity is a constructor parameter; the
* production caller {@code MxGatewayClient.streamEvents} passes {@code 1024} to
* absorb the gateway's session-backlog replay burst). There is intentionally
* <em>no</em> real client flow control: a consumer that stalls long enough to
* let the buffer fill triggers an immediate overflow that cancels the
* subscription and surfaces an {@link MxGatewayException} on the next
* {@link #next()} call. This matches the gateway's documented fail-fast
* event-backpressure design a slow consumer loses its subscription rather
* than silently dropping events. Consumers that cannot keep up must drain
* {@link #next()} promptly (e.g. hand events to their own larger queue) and be
* prepared to resubscribe with a resume cursor.
*
* <p><strong>Threading:</strong> the iterator methods ({@link #hasNext()} and
* {@link #next()}) are <em>not</em> thread-safe and must be driven by a single
* consumer thread. {@link #close()} may be called from any thread. Terminal
* state transitions (queue overflow, server completion, and {@code close()})
* are serialised so that the first terminal condition wins deterministically:
* once an overflow exception has been observed it is never silently replaced
* by an end-of-stream marker.
*/ */
public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable { public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
private static final Object END = new Object(); private static final Object END = new Object();
private final BlockingQueue<Object> queue; private final BlockingQueue<Object> queue;
private final Object terminalLock = new Object();
private volatile ClientCallStreamObserver<StreamEventsRequest> requestStream; private volatile ClientCallStreamObserver<StreamEventsRequest> requestStream;
private volatile boolean closed; private volatile boolean closed;
private boolean terminated;
private Object next; private Object next;
MxEventStream(int capacity) { MxEventStream(int capacity) {
@@ -63,16 +38,7 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
return new ClientResponseObserver<>() { return new ClientResponseObserver<>() {
@Override @Override
public void beforeStart(ClientCallStreamObserver<StreamEventsRequest> requestStream) { public void beforeStart(ClientCallStreamObserver<StreamEventsRequest> requestStream) {
// Resolve the close()/beforeStart() race the same way DeployEventStream does:
// store the request stream first, then check the close flag and cancel the
// call if a prior close() already fired. Without this, a close() that ran
// before the gRPC call attached its ClientCallStreamObserver would skip
// stream.cancel() (because requestStream is still null) and beforeStart()
// arriving afterwards would leak the underlying call open.
MxEventStream.this.requestStream = requestStream; MxEventStream.this.requestStream = requestStream;
if (closed) {
requestStream.cancel("client cancelled event stream", null);
}
} }
@Override @Override
@@ -132,7 +98,7 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
if (stream != null) { if (stream != null) {
stream.cancel("client cancelled event stream", null); stream.cancel("client cancelled event stream", null);
} }
terminate(null); offer(END);
} }
private Object take() { private Object take() {
@@ -149,7 +115,10 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
private void offer(Object value) { private void offer(Object value) {
Objects.requireNonNull(value, "value"); Objects.requireNonNull(value, "value");
if (value == END) { if (value == END) {
terminate(null); if (!queue.offer(value)) {
queue.clear();
queue.offer(value);
}
return; return;
} }
if (!queue.offer(value)) { if (!queue.offer(value)) {
@@ -157,38 +126,9 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
if (stream != null) { if (stream != null) {
stream.cancel("client event stream queue overflowed", null); stream.cancel("client event stream queue overflowed", null);
} }
terminate(new MxGatewayException("gateway stream events queue overflowed"));
}
}
/**
* Drives the single terminal transition. The first caller wins: a later
* end-of-stream or {@code close()} cannot overwrite or discard an overflow
* exception that has already been published to the consumer.
*
* @param fault the fault to surface to the consumer, or {@code null} for a
* clean end-of-stream
*/
private void terminate(MxGatewayException fault) {
synchronized (terminalLock) {
if (terminated) {
return;
}
terminated = true;
if (fault != null) {
// Make room for the fault marker; the consumer only needs the
// terminal signal, queued data events are no longer relevant.
queue.clear();
queue.offer(fault);
queue.offer(END);
return;
}
// Clean end-of-stream: ensure the END marker is delivered even when
// the queue is currently full of undrained data events.
if (!queue.offer(END)) {
queue.clear(); queue.clear();
queue.offer(new MxGatewayException("gateway stream events queue overflowed"));
queue.offer(END); queue.offer(END);
} }
} }
} }
}
@@ -0,0 +1,67 @@
package com.zb.mom.ww.mxgateway.client;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver;
import io.grpc.stub.StreamObserver;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
/**
* Cancellable handle returned by {@code queryActiveAlarms}.
*
* <p>Wraps a caller-supplied {@link StreamObserver} and exposes a
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
* subscription also implements {@link AutoCloseable} so it can participate in
* try-with-resources blocks.
*/
public final class MxGatewayActiveAlarmsSubscription implements AutoCloseable {
private final AtomicReference<ClientCallStreamObserver<QueryActiveAlarmsRequest>> requestStream = new AtomicReference<>();
private final AtomicBoolean cancelled = new AtomicBoolean();
ClientResponseObserver<QueryActiveAlarmsRequest, ActiveAlarmSnapshot> wrap(StreamObserver<ActiveAlarmSnapshot> observer) {
return new ClientResponseObserver<>() {
@Override
public void beforeStart(ClientCallStreamObserver<QueryActiveAlarmsRequest> stream) {
requestStream.set(stream);
if (cancelled.get()) {
stream.cancel("client cancelled active-alarms query", null);
}
}
@Override
public void onNext(ActiveAlarmSnapshot value) {
observer.onNext(value);
}
@Override
public void onError(Throwable error) {
observer.onError(error);
}
@Override
public void onCompleted() {
observer.onCompleted();
}
};
}
/**
* Cancels the underlying gRPC call. Safe to invoke before the call has
* started; cancellation is recorded and applied as soon as the stream
* attaches.
*/
public void cancel() {
cancelled.set(true);
ClientCallStreamObserver<QueryActiveAlarmsRequest> stream = requestStream.get();
if (stream != null) {
stream.cancel("client cancelled active-alarms query", null);
}
}
@Override
public void close() {
cancel();
}
}
@@ -1,4 +1,4 @@
package com.dohertylan.mxgateway.client; package com.zb.mom.ww.mxgateway.client;
import io.grpc.stub.ClientCallStreamObserver; import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver; import io.grpc.stub.ClientResponseObserver;
@@ -1,4 +1,4 @@
package com.dohertylan.mxgateway.client; package com.zb.mom.ww.mxgateway.client;
import io.grpc.CallOptions; import io.grpc.CallOptions;
import io.grpc.Channel; import io.grpc.Channel;

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