Compare commits

..

93 Commits

Author SHA1 Message Date
Joseph Doherty dd7ca1634e Mark code-review findings Server-033..037 resolved
Records the resolutions for the five Galaxy snapshot-persistence findings
fixed in bdccdbf, and regenerates the code-reviews index. Server open
findings drop from 7 to 2 (Server-031, Server-032 remain — unrelated
event-channel backpressure findings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 03:35:56 -04:00
Joseph Doherty bdccdbf6dd Resolve code-review findings Server-033..037
Server-033 (Medium): TryRestoreFromDiskAsync now completes the _firstLoad
gate once the restored snapshot is published, so a browse call racing the
first refresh is served immediately instead of waiting out the 5s bootstrap
budget while an unreachable-database query runs.

Server-034 (Low): GalaxyHierarchySnapshotStore.TryLoadAsync catches
JsonException / IOException / UnauthorizedAccessException and returns null,
honoring the Try contract for a corrupt or unreadable snapshot file.

Server-035 (Low): SaveAsync bounds the write with a linked CancellationToken
(CommandTimeoutSeconds budget) so a stuck disk cannot pin the refresh loop.

Server-036 (Low): PersistSnapshotAsync no longer logs a save cancelled by
gateway shutdown as a persistence failure.

Server-037 (Low): added cache tests for the corrupt-snapshot restore path
and for PersistSnapshot=false, plus a store test for corrupt JSON.

All 100 Galaxy tests pass; gateway builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 03:34:35 -04:00
Joseph Doherty fa491c752b Persist the Galaxy browse dataset to disk for offline startup
The gateway can lose connectivity to the Galaxy database, and the
database is often unreachable exactly when the gateway restarts. The
hierarchy cache was purely in-memory, so a cold start with no database
left clients with an Unavailable browse surface until SQL came back.

Add a JSON snapshot store: each successful heavy refresh writes the raw
hierarchy and attribute rowsets to disk atomically (temp file + rename),
and the first refresh after startup restores that snapshot before any
SQL runs. Restored data is served as Stale until a live query confirms
it; a live query that observes the same time_of_last_deploy promotes it
to Healthy with no heavy re-query.

Persistence is on by default (MxGateway:Galaxy:PersistSnapshot) and
writes to C:\ProgramData\MxGateway\galaxy-snapshot.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:03:00 -04:00
Joseph Doherty aba228f443 Surface built-in primitive attributes in Galaxy browse
AttributesSql enumerated only the dynamic_attribute table (user-configured
attributes), so engine/platform objects came back with zero attributes and
extension sub-attributes (TestAlarm001.Acked, .AckMsg, ...) were missing.
DiscoverHierarchy diverged badly from what System Platform's Object Viewer
shows.

AttributesSql now UNIONs dynamic_attribute with the built-in attributes
every object inherits from its primitives (attribute_definition joined via
primitive_instance). Built-in rows carry no category filter (the
attribute_definition category numbering differs from dynamic_attribute's)
and are never flagged is_historized/is_alarm, since those flags identify a
configured attribute that anchors an extension, not the extension's leaves.
dynamic_attribute wins on a reference collision.

This raises the attribute surface ~7x (verified 2,026 -> 14,334 against the
ZB database). AttributesSql no longer matches the OtOpcUa original;
HierarchySql still does. Column shape, ordinals, proto, and generated code
are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:42:18 -04:00
Joseph Doherty 5e493484f1 Run the Rust CLI on a large-stack worker thread
The clap derive-generated argument parser is deeply recursive; in debug
builds (no inlining) parsing the Command enum exhausted the default
8 MiB main-thread stack once the alarm subcommands grew it, crashing
mxgw.exe with STATUS_STACK_OVERFLOW at startup — which failed the Rust
leg of the client e2e matrix. Move parse + dispatch onto a dedicated
32 MiB worker thread so the CLI is robust regardless of build profile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:12:09 -04:00
Joseph Doherty 3e22285f09 Exercise the alarm subcommands in the client e2e matrix
Add an opt-in alarm phase (-VerifyAlarms) to run-client-e2e-tests.ps1:
each of the five client CLIs runs stream-alarms (asserting at least one
AlarmFeedMessage) and acknowledge-alarm against the gateway's central
alarm monitor. Both RPCs are session-less. -AlarmReference and
-AlarmStreamMax tune the phase; GatewayTesting.md documents it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:47:20 -04:00
Joseph Doherty 120cd0b1b6 Add stream-alarms and acknowledge-alarm to the Python CLI
Brings the Python mxgateway_cli in line with the other four client
CLIs: stream-alarms reads a bounded slice of the gateway's central
alarm feed (--filter-prefix, --max-messages, --timeout);
acknowledge-alarm is a unary session-less ack (--reference required,
--comment, --operator).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:47:19 -04:00
Joseph Doherty 56949c967b Add stream-alarms and acknowledge-alarm to the .NET CLI
stream-alarms attaches to the gateway's central alarm feed (mirrors
stream-events: --max-events cap, --json/--jsonl, --filter-prefix);
acknowledge-alarm is a unary session-less ack (--reference required,
--comment, --operator). Both wired through IMxGatewayCliClient and the
adapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:01:58 -04:00
Joseph Doherty 7dec9b30f5 Add stream-alarms and acknowledge-alarm to the Java CLI
stream-alarms attaches to the gateway's central alarm feed (mirrors
stream-events: --limit cap, --json, --filter-prefix); acknowledge-alarm
is a unary session-less ack (--reference required, --comment,
--operator). Both route through new session-less methods on the CLI
client abstraction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:01:57 -04:00
Joseph Doherty 1d3c8edb44 Add stream-alarms and acknowledge-alarm to the Rust CLI
stream-alarms attaches to the gateway's central alarm feed (mirrors
stream-events: --max-events cap, --json/--jsonl, --filter-prefix);
acknowledge-alarm is a unary session-less ack (--reference required,
--comment, --operator).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:01:49 -04:00
Joseph Doherty 58259016b0 Add stream-alarms and acknowledge-alarm to the Go CLI
stream-alarms attaches to the gateway's central alarm feed (mirrors
stream-events: --limit cap, --json); acknowledge-alarm is a unary
session-less ack (--reference required, --comment, --operator).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:01:48 -04:00
Joseph Doherty 864b9f4bd3 Remove the AlarmClientDiscovery probe log
Delete docs/AlarmClientDiscovery.md — an archival AVEVA alarm-consumer
investigation log whose durable findings now live in the alarm
worker/monitor code. Drop the now-dangling links from Grpc.md and
GatewayConfiguration.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:04:29 -04:00
Joseph Doherty de58872435 Document the session-less StreamAlarms feed and alarm config
Update the gateway docs for the central alarm monitor reversal:
Grpc.md replaces QueryActiveAlarms with the session-less StreamAlarms
RPC and notes AcknowledgeAlarm no longer needs a session;
Authorization.md maps StreamAlarmsRequest to events:read;
GatewayConfiguration.md adds the MxGateway:Alarms options block; and
GatewayDashboardDesign.md points the Alarms page at the central
monitor cache instead of a per-session subscription.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:59:48 -04:00
Joseph Doherty 6777d49030 Point the Java client at the StreamAlarms alarm feed
Regenerate the Java protobuf stubs and replace queryActiveAlarms with
streamAlarms, returning a MxGatewayAlarmFeedSubscription over
AlarmFeedMessage served by the gateway's central alarm monitor
(snapshot, snapshot_complete, then live transitions). Drops session_id
from the acknowledge surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:55:20 -04:00
Joseph Doherty 1b6ca07bb5 Point the Rust client at the StreamAlarms alarm feed
Replace GatewayClient::query_active_alarms with stream_alarms, an
AlarmFeedStream over AlarmFeedMessage served by the gateway's central
alarm monitor (snapshot, snapshot_complete, then live transitions).
Drops session_id from the acknowledge surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:49:26 -04:00
Joseph Doherty 1ad0be8276 Point the Python client at the StreamAlarms alarm feed
Regenerate the Python protobuf stubs and replace query_active_alarms
with stream_alarms, an AsyncIterator over AlarmFeedMessage served by
the gateway's central alarm monitor (snapshot, snapshot_complete, then
live transitions). Drops session_id from the acknowledge surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:45:53 -04:00
Joseph Doherty 9328c4f657 Point the Go client at the StreamAlarms alarm feed
Regenerate the Go protobuf stubs and replace the session-scoped
QueryActiveAlarms surface with the session-less StreamAlarms feed:
snapshot-then-live AlarmFeedMessage fan-out served by the gateway's
central alarm monitor. Drops session_id from the acknowledge surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:45:47 -04:00
Joseph Doherty 0361dc1817 Document the central alarm monitor and fan-out reversal
gateway.md describes the always-on GatewayAlarmMonitor, the session-less
StreamAlarms feed, and session-less AcknowledgeAlarm. DesignDecisions.md
records that the v1 "one subscriber per session / no fan-out" rule is
superseded for the alarm subsystem: alarm state is gateway-wide, so the
gateway monitors it centrally and fans it out to all clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:32:23 -04:00
Joseph Doherty ac12c150c3 Point the .NET client at the StreamAlarms alarm feed
Replace the client's QueryActiveAlarmsAsync with StreamAlarmsAsync —
a session-less subscription to the gateway's central alarm feed that
yields the active-alarm snapshot followed by live transitions.
AcknowledgeAlarm is session-less (AcknowledgeAlarmRequest no longer
carries a session id). Updates the transport interface, the gRPC
transport, the test fake, and the alarm tests; the .NET client
solution builds and its alarm tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:30:50 -04:00
Joseph Doherty 40ca4b6908 Add gateway central alarm monitor and StreamAlarms feed
The gateway now monitors alarms continuously, independent of any client
session, and fans the feed out to every client.

GatewayAlarmMonitor is an always-on hosted service that owns one
gateway-managed worker session dedicated to alarms: it subscribes the
configured provider, caches the active-alarm set from the worker's
transition events (reconciled periodically against the worker's
authoritative snapshot), re-opens the session if the worker faults,
and broadcasts to all subscribers.

The new session-less StreamAlarms RPC opens with the current
active-alarm snapshot, then streams live transitions; any number of
clients fan out from the single monitor without opening a worker
session. AcknowledgeAlarm is now session-less and routes through the
monitor. The session-scoped QueryActiveAlarms RPC and the per-session
alarm auto-subscribe hook are removed, along with the now-dead
IAlarmRpcDispatcher trio; the dashboard Alarms tab reads the monitor's
in-process cache directly.

This intentionally reverses the v1 "no multi-subscriber fan-out"
decision for the alarm subsystem.

Contracts regenerated; gateway, dashboard and tests build clean,
94 alarm-affected tests pass, and the monitor is verified live.
Language-client stubs are regenerated in a follow-up change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:23:56 -04:00
Joseph Doherty bf73985481 Fix hanging and timing-fragile WorkerClient event-channel tests
The Server-032 change made event-channel overflow wait
EventChannelFullModeTimeout before faulting, instead of faulting
instantly. Two pre-existing overflow tests were not updated and left
EventChannelFullModeTimeout at its 5s default, which races the 5s
TestTimeout: ReadLoop_WhenEventQueueOverflows_FaultsClient and
ReadLoop_WhenClientFaults_KillsOwnedWorkerProcess. Pin it to 50ms in
both so overflow faults promptly.

EnqueueWorkerEvent_WhenChannelFullPastTimeout_FaultsWithRichDiagnostic
wrote 6 events into a 4-slot channel, but the worker client faults
while reading the 5th and its read loop then stops — the 6th event is
never drained and the test's pipe write for it blocks forever on a
full OS pipe buffer, hanging the test host. Write exactly 5 (4 to fill
plus 1 to overflow) as the test comment already intends, and bound the
post-fault event drain with TestTimeout so a future regression fails
instead of hanging.

No production change: the Server-031/032 WorkerClient logic is correct
— these were test-only defects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:19:59 -04:00
Joseph Doherty 0a54fa5e35 Render array values and element type in the Browse panel
The subscription panel showed "(array)" for any array-valued tag and
"Unspecified" for its type. A scalar MxValue carries its type in
MxValue.DataType, but an array leaves that Unspecified and carries the
element type and dimensions on the MxArray itself.

DashboardMxValueFormatter.FormatValue now joins the typed MxArray
elements (e.g. "[1.5, 2.25, 3]"), capped at 24 elements with a
"... N total" suffix, and FormatDataType reads the element type and
dimensions off the array (e.g. "Double[10]").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:34:21 -04:00
Joseph Doherty cec84bf572 Harden worker-client heartbeat watchdog and event backpressure
Server-031: HeartbeatLoopAsync now skips the HeartbeatExpired fault
while a command is in flight on the gateway-worker pipe, up to
WorkerClientOptions.HeartbeatStuckCeiling (75s default) — a heartbeat
gap caused by a slow STA command or an event-drain write burst no
longer faults a healthy worker. Mirrors the worker-side Worker-023
guard. A command older than the ceiling still faults so a genuinely
stuck COM call cannot hide the worker indefinitely.

Server-032: EnqueueWorkerEventAsync now honors the configured
EventChannelFullModeTimeout by awaiting WriteAsync against the
wait-mode channel, instead of faulting on the first missed slot with
the non-blocking TryWrite. A transient consumer hiccup is absorbed up
to the timeout; the overflow diagnostic names the channel depth,
capacity, and the actionable fix.

Adds the Server-031 and Server-032 findings entries and WorkerClient
regression tests covering both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:20:09 -04:00
Joseph Doherty 099d4783b0 Fix worker dropping the OnDataChange source timestamp
MXAccess fires OnDataChange with pftItemTimeStamp marshaled as a
VT_BSTR string (e.g. "3/26/2026 1:38:22.907 PM"), not a FILETIME or
VT_DATE. VariantConverter classified it as a plain string, so
ApplySourceTimestamp never set MxEvent.SourceTimestamp — every
OnDataChange event and every cached ReadBulk result carried no
source timestamp for all gRPC clients.

Parse the string and set SourceTimestamp. MXAccess formats it in the
worker host's local time (verified empirically: a fast-changing tag's
timestamp landed exactly the host UTC offset behind wall-clock UTC),
so it is parsed as local and converted to UTC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:53:38 -04:00
Joseph Doherty c1fe7fbc4a Add Browse and Alarms dashboard tabs
Browse renders the Galaxy hierarchy tree from IGalaxyHierarchyCache:
expandable areas/objects with attribute name, data type and the
alarm/historized flags, plus a name/reference filter. Right-click or
double-click an attribute to add it to a subscription panel that polls
live value, quality and source timestamp every two seconds.

Alarms lists the worker's currently-active alarm set via
IAlarmRpcDispatcher, defaulting to unacknowledged Active alarms with
filters for acknowledged alarms, area, severity range and text. It is
read-only and warns when alarm auto-subscribe is disabled.

Both tabs read live MXAccess data through a new singleton
DashboardLiveDataService that owns one shared, lazily-opened gateway
session (one worker) for the whole dashboard, re-opened transparently
if it faults or its lease expires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:53:28 -04:00
Joseph Doherty b39848b5f5 Enable alarm auto-subscribe on session open
Set MxGateway:Alarms with Enabled=true and the dev-rig subscription
expression \DESKTOP-6JL3KKO\Galaxy!DEV. Worker sessions now subscribe
their wnwrap alarm consumer on open; without this the QueryActiveAlarms
RPC returns an empty stream because no session is ever alarm-subscribed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:53:15 -04:00
Joseph Doherty 6126099cdb e2e: drive each client CLI through one long-lived batch process
The cross-language e2e matrix spawned one CLI process per operation —
~250 per client — paying a process (and, for the Java CLI, a full JVM)
cold-start every time. The Java leg alone ran ~16 minutes.

Each client CLI (dotnet, go, rust, python, java) gains a `batch`
subcommand: a single process that reads one command line from stdin,
runs it through the normal subcommand dispatch, writes the JSON result,
then a line containing exactly `__MXGW_BATCH_EOR__`. A failing command
writes its `{"error":...}` envelope and the loop continues.

run-client-e2e-tests.ps1 now launches one batch process per client and
pings every operation through its stdin/stdout, so startup is paid once
per client. The orchestration and assertions are unchanged; the parity
and auth phases now read the `{"error":...}` envelope instead of a
process exit code.

Full 5-client matrix with -VerifyWrite: ~15 min, down from ~35; the Java
leg dropped from ~16 min to ~2-3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 06:20:13 -04:00
Joseph Doherty c1ff8c94e8 e2e: build client CLIs once and drain events so dotnet/java pass
The cross-language client e2e matrix failed for dotnet and Java. Both
failures were in the harness, not the client code.

1. Per-call toolchain cold-start. The matrix issues ~250 CLI calls per
   client; it invoked `dotnet run` / `gradle :mxgateway-cli:run` every
   time, rebuilding and cold-starting the toolchain per call. Build each
   CLI once up front (`dotnet build`, `gradle :mxgateway-cli:installDist`)
   and invoke the compiled artifact directly. This alone fixes dotnet.

2. Worker event-channel overflow. The per-tag advise loop advises every
   discovered tag with no StreamEvents consumer attached, so change
   events accumulate in the worker event channel
   (MxGateway:Events:QueueCapacity) until FailFast faults the worker.
   dotnet's faster loop slipped under the window; the Java CLI's
   process-per-call JVM cold-start did not. Every -DrainEveryTags advised
   tags (default 15) the loop connects a short StreamEvents drain; the
   gateway's per-stream producer empties the channel the instant a
   subscriber attaches, so a small bounded read suffices.

Full 5-client matrix (dotnet, go, rust, python, java) now passes with
-VerifyWrite against a live gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:24:24 -04:00
Joseph Doherty b794c46bc7 File and fix Server-030 and Client.Dotnet-017 from e2e surfacing
Both findings surfaced when running the cross-language e2e matrix
(scripts/run-client-e2e-tests.ps1) against the redeployed gateway at
commit 84d36b7. Filed in code-reviews/Server/findings.md and
code-reviews/Client.Dotnet/findings.md and fixed in the same change.

Server-030 (Medium / Error handling): GatewaySession.GetReadyWorkerClient
gated on `_state == Ready && _workerClient.State == Ready` but only
formatted `_state` into the SessionManagerException message. Under load
the gateway-driven `_state` and the worker-driven `WorkerClient.State`
can diverge, producing a self-contradictory diagnostic ("Session ... is
not ready. Current state is Ready."). The Java e2e client hit this on
the 56th item after 55 successful add-items. Rewrote the message to
include both states ("Session state is X; worker state is Y"), added
an XML doc explaining the two-state contract and that this branch is
the fail-fast for a divergence race, and added regression test
SessionManagerTests.InvokeAsync_WhenWorkerNotReadyButSessionReady_DiagnosticIncludesBothStates
that pins both states appear in the message. The deeper race (should
the gateway briefly wait for worker-Ready before failing?) remains
open as a follow-up.

Client.Dotnet-017 (Low / Error handling): stream-events CLI threw
OperationCanceledException as an unhandled exception when the user's
--timeout expired before --max-events was reached. Exit code
-532462766, no aggregate JSON. The other client CLIs (Go, Rust, Python,
Java) exit 0 in this case. Wrapped the `await foreach` in
`catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)`
so the supplied token's cancellation (--timeout, Ctrl+C, or parent
CTS) becomes graceful completion; the aggregate `{ "events": [...] }`
JSON still runs after the catch. Added regression test
RunAsync_StreamEvents_WhenTimeoutFiresAfterEvents_EmitsCollectedEventsAndExitsZero
backed by a new FakeCliClient.StreamHangAfterEvents hook that yields
the configured events then parks on the cancellation token.

Side cleanup: the GatewayApplicationTests test added under Server-020
was asserting an invariant (`/dashboard/dashboard/X` doesn't exist)
that I broke by reverting Server-020 in 84d36b7. The doubled endpoint
shapes do exist now (MapGroup("/dashboard") prefixing an already
"/dashboard/X" @page directive) but they're harmless — no client
requests `/dashboard/dashboard/X`. Replaced the test with a positive
assertion (`/dashboard/X` routes ARE registered) and rewrote the XML
doc to record the actual contract.

Verified: dotnet test src/MxGateway.Tests passes 480/480, dotnet test
clients/dotnet/MxGateway.Client.Tests passes 77/77, gateway redeployed
at this commit and GET http://localhost:5130/dashboard returns 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:07:39 -04:00
Joseph Doherty 84d36b7638 Restore dashboard @page "/dashboard/X" directives — Server-020 reversal
Server-020 incorrectly removed the duplicate `@page "/dashboard/X"` directive
from each dashboard Razor page on the assumption that `MapGroup("/dashboard")`
would prepend the prefix to Blazor SSR route matching. It does not — Blazor's
`@page` template matcher operates on the full URL path, not relative to a
MapGroup. The removal left the dashboard returning HTTP 500 with
"Unable to find the provided template '/dashboard/'" from
RouteTableFactory.CreateEntry on every page.

Restored the eight `@page "/dashboard/X"` directives. The accompanying
regression test still passes (it asserts the genuinely-double-prefixed shape
`/dashboard/dashboard/X` never appears — it never did, since the original
duplicates were `"/"` + `"/dashboard/"`, not `"/dashboard/"` repeated). XML
doc on the test rewritten to record what was actually wrong with Server-020.

Verified: gateway redeploy + `GET http://localhost:5130/dashboard` returns
HTTP 200 7.3 KB; `/dashboard/sessions` also 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:07:18 -04:00
Joseph Doherty 1aafd6bde4 Code-review 2026-05-20 sweep #2: re-review at a020350, resolve 48 findings
Second re-review pass at commit a020350 caught 48 new findings — including
one High-severity regression I introduced in the prior sweep — and fixed
them all in one parallel wave.

High (1)
- Client.Python-018: prior sweep set `license = "Proprietary"` in
  pyproject.toml. setuptools >= 77 enforces PEP 639 and rejects the
  string (it must be a valid SPDX expression), so `pip wheel .` and
  `pip install -e .` both fail before any source compiles. Tests
  still pass because pytest bypasses the build backend via
  `pythonpath`. Dropped the invalid license string, kept the
  `License :: Other/Proprietary License` classifier, and added
  `tests/test_packaging.py` so a future regression of the same shape
  is caught in CI.

Mediums (6)
- Worker-023: `HeartbeatStuckCeiling` (default 75s = 5x HeartbeatGrace)
  on WorkerPipeSessionOptions bounds the in-flight-command watchdog
  suppression so a truly stuck COM call still triggers StaHung
  instead of permanently defeating the watchdog.
- Client.Rust-018: reverted Rust's `latencyMs` split so the
  cross-language bench comparison is apples-to-apples again;
  `failureLatencyMs` kept as Rust-only enrichment.
- Client.Java-021: applied Client.Java-002's terminal-state
  serialisation pattern to DeployEventStream so close() arriving
  after queue-overflow can't erase the overflow exception.
- IntegrationTests-017: teardown-parity test now uses a two-window
  stability check after UnAdvise instead of strict equality against
  the pre-UnAdvise count (which raced against in-flight events).
- IntegrationTests-019: new RecordingTestOutputHelper wraps every
  log sink the WriteSecured live test owns (worker stdout/stderr,
  gateway logs, direct WriteLine) so the credential is proven
  absent from the full output buffer, not just the diagnostic
  message.
- Tests-020: added MxAccessGatewayServiceConstraintTests coverage
  for the previously-uncovered Write2Bulk and WriteSecured2Bulk
  arms of WriteBulkConstraintPlan.SetPayload.

Lows (41 — highlights)
- Server: Galaxy glob cache eviction is race-free (Server-024);
  GalaxyRepositoryGrpcService takes IGalaxyRepository (Server-025);
  AlarmsOptions validated at startup (Server-026); Authorization.md
  Constraint Enforcement snippet/prose enumerate the bulk write/read
  family (Server-027); bulk-read-commands and bulk-write-commands
  capability tokens added to OpenSession (Server-029);
  NotWiredAlarmRpcDispatcher XML doc and missing scope-resolver and
  state-machine tests cleaned up (023, 028).
- Worker: AlarmCommandHandler now invokes the same STA-affinity
  guard the poll path uses, at every command entry (Worker-024);
  RunAsync null-checks the runtime-session factory result
  (Worker-025).
- Worker.Tests: shared LiveMxAccessOptInVariableName lives on
  GatewayContractInfo (Worker.Tests-025); MxAccessSession.CreateForTesting
  rejects production sinks (Worker.Tests-026); FakeRuntimeSession's
  CancelCommandReturnValue serialised under lock (Worker.Tests-027);
  Probes namespace lifted to MxGateway.Worker.Tests.Probes
  (Worker.Tests-029); cancel-envelope sequence numbers monotonised
  (Worker.Tests-030); docs/GatewayTesting.md gains a "Dev-rig Probes"
  section (Worker.Tests-028).
- Tests: ManualTimeProvider consolidated into one TestSupport/ copy
  (Tests-021); SessionManagerBulkTests adds a mid-flight cancellation
  test backed by a TaskCompletionSource fake (Tests-022); companion
  FakeWorkerProcess.WaitForExitAsync no longer fakes its exit signal
  (Tests-023); constraint plan reply-count divergence pinned
  (Tests-024).
- IntegrationTests: TryGetSession chain carries [MaybeNullWhen(false)]
  end-to-end (IntegrationTests-018); abnormal-exit keyword set
  tightened to pipe-disconnected/end-of-stream and the test now
  asserts streamTask.IsFaulted (020, 021).
- Client.Dotnet: bench commands added to isLongRunning so the
  default 30s wall-clock budget doesn't kill them (015);
  BenchStreamEventsAsync observes the inner stream task on every
  exit path (016).
- Client.Go: parseValue wraps strconv errors with flag context and
  %w (017); bench loops honour ctx.Done() (018); galaxy-watch parses
  RFC3339Nano with fractional seconds (019); runStreamEvents installs
  signal.NotifyContext like runGalaxyWatch (020); five new CLI-level
  table-driven tests cover the bulk/bench subcommands (021).
- Client.Java: toCompletable Javadoc rewritten to match the actual
  cancellation contract Client.Java-015 established (022); stream-events
  text path uses Long.toUnsignedString for worker_sequence (023);
  bench-read-bulk no longer pollutes success-latency histogram with
  failure durations (024); --shutdown-timeout CLI option propagates
  through to ClientOptions (025); seven new MxGatewayCliTests cover
  the bulk and bench commands (026).
- Client.Python: mxgateway_cli ships its own py.typed marker (019);
  wheel-build smoke test added under tests/test_packaging.py (020);
  README documents the Galaxy CLI parity gap explicitly (021).
- Client.Rust: RustClientDesign.md signatures match session.rs and
  document the AsRef<str> read_bulk genericism (019);
  next_correlation_id re-exported at the crate root, with a
  property-style doc contract and an explicit disclaimer that the
  literal textual format is not part of the contract (020).
- Contracts: BulkWriteResult comment names the actual
  IConstraintEnforcer mechanism instead of "tag-allowlist filter"
  (014); BulkReadResult gains explicit per-arm payload-population
  documentation for the success vs failure cases (015).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:28:54 -04:00
Joseph Doherty a0203503a7 Code-review 2026-05-20 sweep: re-review at 1cd51bb, resolve 72 findings across all 11 modules
Re-reviewed every module/client against the 10-category checklist
(REVIEW-PROCESS.md) at commit 1cd51bb, filed 72 new findings, and
fixed them in three priority waves (3 High, 17 Medium, 52 Low).

Highs
- Server-017: enumerate AcknowledgeAlarm / QueryActiveAlarms in
  GatewayGrpcScopeResolver so non-admin keys can use them; document
  the mapping in docs/Authorization.md; add interceptor tests.
- Client.Java-013: add the five missing bulk-method stubs to the
  CLI FakeSession so the test module compiles on a clean tree.
- Client.Rust-013: fix the clippy::doc_lazy_continuation regression
  in generated tonic code by reformatting the ReadBulkCommand proto
  comment and scoping a #![allow(...)] to the generated submodules.

Mediums (highlights)
- Server: unify GatewaySession state-lock discipline (-015) and
  make DisposeAsync race-safe against in-flight CloseAsync (-016);
  add constraint-enforcement test coverage for the bulk-plan path
  (-021).
- Worker: introduce StaRuntimeShutdownException so RunAlarmPollLoop
  can distinguish graceful shutdown from a real STA-affinity
  violation (-016); have the watchdog skip StaHung while
  CurrentCommandCorrelationId is non-empty so a legitimate slow
  ReadBulk no longer self-faults (-017).
- Tests: add per-method round-trip + cancellation coverage for the
  11 GatewaySession bulk methods (-013); replace the real TCP probe
  in GalaxyHierarchyCacheTests with an IGalaxyRepository fake
  (-016).
- IntegrationTests: drive the StreamEvents writer in the live Write
  test and assert OnWriteComplete (-012); add live tests for
  Unadvise/RemoveItem/Unregister ordering, WriteSecured, and
  abnormal worker exit (-014).
- Worker.Tests: replace MxAccessSession reflection with an internal
  CreateForTesting factory (-016); cover WorkerCancel and
  unexpected-body envelope branches (-017).
- Client.Java: cancel MxEventStream when close() races
  beforeStart() (-014); return a CancellingCompletableFuture that
  actually forwards cancellation through .thenApply chains (-015).
- Client.Python: drop the silent localhost-plaintext downgrade in
  the CLI; require explicit --plaintext (-013).
- Client.Rust: stop bench-read-bulk from polluting success-latency
  histograms with failed-call durations (-015); add coverage for
  the five MalformedReply paths, the bulk-write helpers, the
  Error::Unavailable mapping, and the unary-fault path (-016).
- Contracts: extend docs/Contracts.md with the bulk read/write
  command family (-009).

Lows (highlights)
- Server: cap GalaxyGlobMatcher.RegexCache; align
  WorkerAlarmRpcDispatcher missing-session handling; drop the
  duplicate dashboard @page routes; refresh IAlarmRpcDispatcher
  XML doc.
- Worker: surface SetXmlAlarmQuery COM failures; remove dead
  subscriptionExpression / ExecutingCommand arms; preserve
  factory-supplied runtime sessions; split MxAlarmSnapshot.cs into
  three files.
- Tests: dispose the WebApplication in seven test classes; rebuild
  FakeWorkerProcess.WaitForExitAsync against a real TaskCompletion
  source; switch the heartbeat-expires test to ManualTimeProvider;
  add InvariantCulture to the remaining DateTimeOffset.Parse sites;
  document GalaxyFilterInputSafetyTests in GatewayTesting.md.
- IntegrationTests: comment fixes, RecordingServerStreamWriter
  IDisposable, class-level [Trait], single-source ZB default
  connection string.
- Worker.Tests: replace silent-return gating with LiveMxAccessFact
  so absent env vars SKIP not pass; PascalCase rename of probe
  [Fact]s; deterministic deadline test; new frame-protocol error
  tests; ComputeTransitions diff-coverage; relocate dev-rig probes
  to Probes/.
- Contracts: add round-trip coverage and per-field redaction /
  Galaxy-identifier comments to the protos.
- Client.Dotnet: introduce clients/dotnet/Directory.Build.props so
  TreatWarningsAsErrors / analysers apply; document
  DiscoverHierarchyOptions and IMxGatewayCliClient; require typed
  bulk-read handles in CLI; surface AcknowledgeAlarm transport
  faults through Translate().
- Client.Go: kill dead code in alarms_test / fakeGalaxyServer /
  runWriteBulkVariant; document the six new subcommands in
  writeUsage; drain galaxy-watch events on limit; switch io.EOF
  comparisons to errors.Is.
- Client.Java: shared shutdown helpers + new shutdownTimeout
  option; regex-based credential redaction; Long.toUnsignedString
  for uint64 sequence; doc fixes.
- Client.Python: combine duplicate imports; add coverage for
  _percentile / bench-read-bulk / MAX_AGGREGATE_EVENTS /
  _api_key_from_env; populate pyproject metadata and ship py.typed.
- Client.Rust: expose next_correlation_id() so CLI ping/close
  stop hard-coding correlation IDs; resync RustClientDesign.md
  with the current Session / Error surface and CLI subcommand set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:46:47 -04:00
Joseph Doherty 1cd51bbda3 .NET CLI: bench-stream-events for max event-throughput characterization
New subcommand drives the gateway''s StreamEvents server-stream as fast
as it can from a single client process. Subscribes to --bulk-size tags
(rotating through all six TestMachine attributes by default) and counts
events received over a --duration-seconds steady-state window. Tracks
events/sec, end-to-end latency (now - event.worker_timestamp), and any
worker faults observed via a post-run DrainEvents probe.

--session-count opens N independent gateway sessions from the same
client process — each session is independent at the gateway (own
worker, own event subscriber, own item handles) so this measures how
the gateway multiplexes concurrent event streams without needing
multiple client processes. Sessions are staggered open by default
(--session-start-stagger-ms 750) because firing N concurrent
OpenSession calls forces N concurrent worker x86 spawns, and on a dev
rig that exceeds the gateway''s 30-second worker startup timeout
around N >= 6-8. The stagger gives each worker headroom to init its
COM apartment + attach the event sink before the next one starts.

Phase 1 of the bench opens + subscribes every session sequentially;
phase 2 opens the steady-state window once everyone is advised, so
the measurement isn''t skewed by late-arriving sessions still in
warmup. The latency sample is shared across sessions (locked
List<double>); event counts use Interlocked.

Initial sweep at --bulk-size 120 against the dev galaxy (20 machines
x 6 attributes = 120 unique tags) showed:

  - Linear throughput scaling with subscribed-tag count: N=6→2 ev/s,
    N=24→8 ev/s, N=60→20 ev/s, N=120→41 ev/s. The dev galaxy is
    producer-bound at ~0.34 events/sec per advised tag — gateway has
    plenty of headroom.
  - Latency stayed at p50 ≈17ms, p95 ≈34ms across the entire range —
    no degradation with subscribed-tag count.
  - Zero queue-overflow faults; gateway 10k-event buffer never came
    close to filling at this producer rate.
  - Linear scaling with session count too (staggered open): 1→44, 2→81,
    4→130, 8→324 events/sec at p50 16ms across all session counts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:30:24 -04:00
Joseph Doherty 61644e63fb Rust: take Session::read_bulk tag list by borrowed slice
Changes the public signature from `Vec<String>` (owned) to
`&[impl AsRef<str>]` so callers can re-issue the same call repeatedly
without cloning at the call site. The bench''s steady-state loop now
passes `&tags` instead of `tags.clone()`; the CLI subcommand passes the
parsed `&items`; the integration test passes `&["Area001.Pump001.Speed"]`
straight from a string literal slice.

Honest perf note: this is an ergonomics change, not a measurable speedup.
The method still has to materialise an owned `Vec<String>` internally
because prost''s generated `ReadBulkCommand` field requires it, so the
total heap traffic per call is unchanged. Across two 30-second, 5-way
concurrent bench runs at bulkSize=6:

  pre-fix  (.clone() at caller): 145.35 calls/sec, p99  62.31 ms
  post-fix run 1 (&tags):        165.98 calls/sec, p99  40.65 ms
  post-fix run 2 (&tags):        146.19 calls/sec, p99  60.04 ms

Run-to-run variance (145-166) dominates any signal from the fix. Solo
Rust release stayed at 261-267 calls/sec across both API shapes,
confirming the bench is gateway-bound under 5-way contention rather
than client-allocation-bound. The change is kept because the borrowed
slice is the idiomatic Rust API shape for "list of items the callee
does not need to take ownership of", and it cleans up the explicit
clone from the bench's inner loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 05:49:33 -04:00
Joseph Doherty 7db4bffa30 bench-read-bulk driver: invoke .NET in -c Release and Rust in --release
Rust''s debug profile costs the bench ~45% of solo throughput and ~3x of
p99 latency vs release (267 vs 184 solo calls/sec, p99 5.7 vs 16ms).
Debug disables inlining, runs overflow checks on every arithmetic op,
keeps Future state machines un-collapsed, and lets every Vec allocation
through unoptimized. Other compiled clients in the matrix don''t see
this gap: Go always builds optimized, Python is interpreted, and the
JIT-tiered runtimes (HotSpot for Java, CoreCLR Tier 1 for .NET) close
most of the gap during the warmup window.

The driver now requests `cargo run --release` for Rust and `dotnet run
-c Release --no-build` for .NET, so the two compiled-AOT clients race
under their production-equivalent profiles. Callers must `cargo build
--release -p mxgw-cli` and `dotnet build ... -c Release` once before
running the bench; `--no-build` then keeps each measurement window
free of compilation overhead.

Live re-run (5-way concurrent, 30s, bulkSize 6) after the switch:
  rust:  145.35 calls/sec (was 123.26 in debug; 18% gain under contention)
  go:    185.59 calls/sec
  java:  171.80 calls/sec
  dotnet:172.31 calls/sec
  python:140.52 calls/sec

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 05:25:17 -04:00
Joseph Doherty 93633ce99c Cross-language ReadBulk stress benchmark
Adds a bench-read-bulk subcommand to every client CLI (.NET, Go, Rust,
Python, Java) and a PowerShell driver that runs all five concurrently
against the deployed gateway and prints a side-by-side comparison.

Each CLI''s bench:

  - Opens its own session, registers, subscribes to bulk-size tags so the
    worker''s MxAccessValueCache populates from real OnDataChange events.
  - Runs a warmup-seconds-long pre-loop with identical calls so JIT /
    connection-pool / first-call overhead is amortised before the
    measurement window.
  - Runs ReadBulk in a tight in-process loop for duration-seconds with
    per-call high-resolution latency capture (Stopwatch in .NET,
    time.Now in Go, std::time::Instant in Rust, time.perf_counter in
    Python, System.nanoTime in Java).
  - Unsubscribes + closes the session, then emits one JSON object with
    the shared schema: { language, durationMs, totalCalls, successfulCalls,
    failedCalls, totalReadResults, cachedReadResults, callsPerSecond,
    latencyMs: { p50, p95, p99, max, mean } }.

The PS driver (scripts/bench-read-bulk.ps1) launches one detached process
per client, waits for all to finish, parses the trailing JSON object from
each stdout, prints a comparison table, and persists the combined report
under artifacts/bench/. Quoting around Java''s `gradle --args="..."` is
handled by writing a one-shot .bat that cmd.exe runs; the .NET CLI''s
per-call gRPC timeout is auto-scaled to (Duration + Warmup + 30s) so the
channel-wide timeout doesn''t cancel the bench mid-loop.

Live 30-second steady-state run against the deployed gateway, all five
clients hitting the same six TestMachine_001..006.TestChangingInt tags:

  client    calls/sec  cached/total    p50 ms  p95 ms  p99 ms  max ms
  dotnet      171.78   30924/30924      3.84   14.06   40.41  542.48
  go          175.46   31590/31590      3.93   13.52   41.26  243.00
  rust        123.26   22188/22188      5.52   15.78   48.11  544.41
  python      145.79   26244/26244      4.86   14.85   41.65  645.84
  java        181.12   32604/32604      3.80   10.59   33.37  344.27

143,550 ReadBulk results across all five clients during the 30s window;
100% were was_cached = true (the worker''s cache fast-path never fell
through to the snapshot lifecycle). Aggregate read throughput ~800
calls/sec against five concurrent sessions sharing the same cached tags.

A second variant with bulk-size 20 sustained the same per-client call
rate while delivering 3.3x more values per call (~37,000 cached reads/sec
aggregate across the five concurrent sessions), confirming the linear
per-tag cache lookup inside one call is not a bottleneck at this scale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 05:17:08 -04:00
Joseph Doherty eaa7093cd6 .NET CLI: register the five new bulk subcommands in IsKnownGatewayCommand
The previous commit added read-bulk / write-bulk / write2-bulk /
write-secured-bulk / write-secured2-bulk dispatch cases to RunCoreAsync
but left them out of IsKnownGatewayCommand, so the .NET CLI rejected
them at the pre-dispatch gate and printed the usage banner instead of
running the new code paths. Surfaced when the live e2e exercised the
read-bulk phase against the deployed gateway — the call routed through
the unknown-command path before reaching the protobuf builder.

Also extends WriteUsage with one line per new subcommand so the banner
documents the new surface.

Live e2e against the deployed gateway now passes for all five clients
(dotnet, go, rust, python, java) with 4/4 tags returning was_cached=true
after the subscribe-bulk + read-bulk path, confirming the worker
MxAccessValueCache populates from real MXAccess OnDataChange events and
round-trips through every client''s JSON parser.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 04:48:55 -04:00
Joseph Doherty f220908f3f Add bulk read/write CLI subcommands and e2e matrix coverage
The previous commit added the bulk read/write library surface in every
client; this commit makes that surface reachable from each client's CLI
and exercises it through scripts/run-client-e2e-tests.ps1.

Five new subcommands in every client CLI (.NET / Go / Rust / Python /
Java): read-bulk, write-bulk, write2-bulk, write-secured-bulk, and
write-secured2-bulk. Each follows the existing subscribe-bulk shape:

  - read-bulk takes --server-handle, --items <csv tag list>, and
    --timeout-ms (0 = worker default). JSON output carries the
    BulkReadResult fields, including was_cached so the e2e matrix can
    verify the cached-path semantics.
  - The four bulk-write families take --server-handle, --item-handles
    <csv>, --type, --values <csv>. write2-bulk and write-secured2-bulk
    add a single --timestamp applied to every entry; the secured
    variants take --current-user-id and --verifier-user-id. All four
    output BulkWriteResult JSON.

A new -SkipReadWriteBulk switch on the matrix script (default OFF)
controls two new e2e phases:

  - After the existing subscribe-bulk phase leaves tags advised, the
    script runs read-bulk against the same tag list and asserts most
    results return was_cached = true. This is the only e2e coverage of
    the cache-then-snapshot fork — the unit + gateway tests verify the
    semantics with a fake worker, but only the live cross-language
    matrix proves the cache populates from real OnDataChange events and
    survives the round-trip through every client''s JSON parser.
  - When -VerifyWrite is set, the write phase now also runs a single-
    entry write-bulk against the same writable item handle (using a
    distinct sentinel value) and asserts a per-entry success. Confirms
    the BulkWriteResult wire format end-to-end without complicating
    the OnWriteComplete echo assertion the single-item phase already
    verifies.

Dry-run validation passes for all five clients: each emits the correct
read-bulk and write-bulk CLI invocations with the right flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 04:06:14 -04:00
Joseph Doherty 5e375f6d3d Add bulk read/write command family across worker, gateway, and clients
Adds five new MXAccess command kinds (WriteBulk, Write2Bulk,
WriteSecuredBulk, WriteSecured2Bulk, ReadBulk) that ride the existing
"one round-trip, per-entry results" bulk shape used by AddItemBulk and
SubscribeBulk today. MXAccess COM has no native bulk API; the worker
runs each bulk operation as a sequential loop on its STA, returning
one BulkWriteResult / BulkReadResult per requested entry so per-item
MXAccess failures surface as was_successful=false rather than throwing.

ReadBulk has no MXAccess analogue. The worker satisfies it by:

  - Returning the last cached OnDataChange payload (was_cached=true)
    when the requested tag is already in the session''s item registry
    AND advised — the existing subscription is NOT touched, since the
    caller did not create it.
  - Otherwise taking the AddItem + Advise + wait-for-OnDataChange +
    UnAdvise + RemoveItem snapshot lifecycle itself (was_cached=false)
    and leaving the session exactly as it was. The wait pumps Windows
    messages on the STA so the inbound MXAccess event can dispatch
    while the executor still holds the thread.

The new MxAccessValueCache lives on each MxAccessSession, shared with
MxAccessBaseEventSink which populates it on every OnDataChange after
the event clears the outbound queue. Eviction on RemoveItem keeps
reused MXAccess handles from serving stale values from a previous
lifetime.

Gateway-side authorization wires WriteBulk/Write2Bulk to invoke:write,
WriteSecuredBulk/WriteSecured2Bulk to invoke:secure, ReadBulk to
invoke:read. The constraint-filter pipeline is refactored from a single
BulkConstraintPlan record into an abstract base plus three concretes
(SubscribeBulk, WriteBulk, ReadBulk), each owning its own denied-entry
merge so the dispatch site never branches on reply shape. A new
FilterWriteBulkAsync<TEntry> generic over the four write-entry shapes
runs CheckWriteHandleAsync per entry; denied entries surface as the
BulkWriteResult shape, preserving original-index order.

All five language clients (.NET, Go, Rust, Python, Java) gained the
five new methods following their existing bulk pattern, with regenerated
protobufs.

Tests added:
  - MxAccessValueCacheTests (6 cases) — Set/TryGet, Remove resets the
    version, TryWaitForUpdate signals on Set, pump step fires each poll.
  - MxAccessBaseEventSinkTests — OnDataChange populates the cache,
    ValueCache property exposes the bound instance.
  - MxAccessCommandExecutorTests — four bulk-write variants (per-entry
    success/failure, value+timestamp forwarding, secured user ids),
    ReadBulk snapshot lifecycle on uncached tag (timeout surfaces as
    was_successful=false), invalid-payload reply.
  - GatewayGrpcScopeResolverTests — five new MxCommandKind cases.
  - SessionManagerTests — WriteBulk and ReadBulk forwarding through
    FakeWorkerHarness; ReadBulk forwards timeout_ms.
  - Per-client (.NET, Go, Rust, Python, Java) — WriteBulk builds the
    right command and returns per-entry results, ReadBulk forwards the
    timeout and unpacks the was_cached flag.

Cross-language e2e CLI subcommands for the new bulks are deliberately
scoped out of this change (each of the five client CLIs would need
five new subcommands plus matching phases in
scripts/run-client-e2e-tests.ps1); coverage equivalent to the existing
bulk-subscribe coverage is provided by worker + gateway + per-client
unit tests.

Docs updated in the same commit: gateway.md (Public MXAccess Command
Surface), docs/DesignDecisions.md (new "Bulk Command Family" section
with the ReadBulk cache-then-snapshot rationale), and every client
README.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:42:38 -04:00
Joseph Doherty 758aca2355 Make the e2e write phase work live across all five clients
Running the matrix against a live gateway surfaced several issues:

- The write phase is now opt-in (-VerifyWrite, was -SkipWrite). It runs
  right after register so only a small event backlog precedes the write,
  and asserts the reliable OnWriteComplete signal (the written value is
  not echoed back by a provider-driven attribute like TestChangingInt, so
  the value compare is best-effort).
- Java was launched as bare "gradle", which .NET's Process.Start cannot
  exec (it is gradle.bat) — resolve the launcher and run it via cmd.exe.
- The Java client's MxEventStream queue capacity was 16, which overflows
  on any active session's backlog-replay burst; raised to 1024.
- The Rust stream-events CLI now renders the event family as the proto
  enum name, matching the protobuf-JSON the other four clients emit.

Update docs/GatewayTesting.md for the reworked write phase.

Verified live: the full five-client matrix passes with -VerifyWrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:45:47 -04:00
Joseph Doherty 06030dd1ef Implement MXAccess write commands in the worker
The .proto contract and MxCommandKind already defined Write, Write2,
WriteSecured, and WriteSecured2, but the worker's MxAccessCommandExecutor
had no case for any of them — every write kind fell through to
CreateInvalidRequestReply ("Unsupported MXAccess command kind Write").

Implement all four:

- VariantConverter.ConvertToComValue projects an MxValue into a
  COM-marshalable object (scalars, arrays, null) — the inverse of the
  existing COM-to-MxValue projection.
- IMxAccessServer / MxAccessComServer gain Write/Write2/WriteSecured/
  WriteSecured2, routed to ILMXProxyServer / ILMXProxyServer4.
- MxAccessSession and MxAccessCommandExecutor add the four write paths,
  following the existing ExecuteAdvise pattern; the reply is a plain OK
  reply and the outcome surfaces later as an OnWriteComplete event.

Verified live: a Write now returns PROTOCOL_STATUS_CODE_OK and produces
an OnWriteComplete event where it previously returned InvalidRequest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:45:35 -04:00
Joseph Doherty e355a7674b Add write, parity, auth, and parallel coverage to client e2e matrix
Close the notable gaps in scripts/run-client-e2e-tests.ps1:

- Write round-trip: write a per-client sentinel value to a configurable
  writable attribute, then assert it is echoed back through the event
  stream. Extends the Rust mxgw-cli stream-events output with full
  per-event JSON (itemHandle + protojson-shaped value) so all five
  language clients run an identical value compare.
- Parity: assert an invalid item handle and an unknown session id are
  rejected rather than silently succeeding.
- Auth rejection: assert open-session is rejected with a missing API key
  and, when -RejectScopeApiKeyEnv is supplied, with an insufficient-scope
  key.
- Parallel: -Parallel runs each language client as an isolated child
  process and merges their JSON reports.

Update docs/GatewayTesting.md for the new phases and flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:55:51 -04:00
Joseph Doherty cd92048f4e Regenerate stale Java client protobuf code
The checked-in generated Java sources under clients/java/src/main/generated/
were out of sync with both the .proto contracts and the configured
protobuf 4.33.1 toolchain: they were missing the alarm command kinds
(MX_COMMAND_KIND_SUBSCRIBE_ALARMS..ACKNOWLEDGE_ALARM_BY_NAME, 25-29), the
alarm/galaxy message additions, and the protobuf 4.x generated-code layout.
Regenerated via `gradle generateProto`; `gradle test` passes against the
refreshed sources. No hand edits — pure protoc/protoc-gen-grpc-java output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:21:13 -04:00
Joseph Doherty 964b40dcbc Fix stale WorkerProjectReferenceTests MXAccess-interop assertion
MxAccessInteropReference_ExistsOnlyInWorkerProject asserted the MXAccess COM
interop was referenced only by MxGateway.Worker. The worker test project now
legitimately references ArchestrA.MxAccess and Interop.WNWRAPCONSUMERLib so it
can exercise the COM-facing worker code (WnWrapAlarmConsumer, the alarm
tests). Renamed to ..._ExistsOnlyInWorkerAndWorkerTestProjects, updated the
assertion to expect both projects, and made it order-independent. The
architecture invariant the test protects — the gateway/contracts never
reference MXAccess COM — still holds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:19:32 -04:00
Joseph Doherty bb5603b7ec Fix flaky GalaxyHierarchyRefreshServiceTests timing race
ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFault
BackgroundService cancelled the service immediately after StartAsync, so
under parallel load the first RefreshAsync could be skipped (RefreshCallCount
0) and `await executeTask` rethrew TaskCanceledException before the IsFaulted
assertion. The test now waits for a TaskCompletionSource signal that the
first refresh was attempted before cancelling, and uses Task.WhenAny so a
Canceled ExecuteTask does not rethrow. Confirmed stable across full-suite
runs (408/408).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:14:47 -04:00
Joseph Doherty 24de7e21d9 Regenerate code-reviews index after Low findings Batch 3
Reflects resolution of Contracts-001/004/005/006/007/008 (and Contracts-003
re-triaged Won't Fix). All code-review findings across every module are now
closed. Also normalizes the Contracts-003 Status to the canonical
`Won't Fix` value the index generator expects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:12:34 -04:00
Joseph Doherty ee959e46e6 Resolve Contracts-001/004/005/006/007/008 code-review findings
Contracts-001: docs/Grpc.md still described "four MxAccessGateway RPCs" —
updated to the actual six (adding AcknowledgeAlarm and QueryActiveAlarms to
the handler and validation-rule sections).

Contracts-003 (Won't Fix): the finding is factually wrong — the <Protobuf>
item for mxaccess_worker.proto already sets ProtoRoot="Protos"; all three
items are consistent (confirmed back to the reviewed commit).

Contracts-004: corrected the stale GatewayContractInfo XML summary
("before generated protobuf contracts are introduced").

Contracts-005: no proto field/enum value was ever removed, so no reserved
ranges were invented. Added a wire-compatibility policy comment to all three
.proto files instructing future editors to reserve removed numbers.

Contracts-006: documented MxStatusProxy.success — it mirrors the COM
MXSTATUS_PROXY numeric success member, is not a boolean, and clients should
branch on category.

Contracts-007: added 13 round-trip tests covering galaxy_repository.proto
messages, bulk-subscribe payloads, and raw-value/IPC worker bodies.

Contracts-008: WorkerAlarmRpcDispatcher never assigns AcknowledgeAlarmReply.
status, so the old "native status" proto comment was misleading. Corrected
the hresult/status proto comments and documented the worker native_status →
public reply mapping in AlarmClientDiscovery.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:12:00 -04:00
Joseph Doherty 771229b39f Regenerate code-reviews index after Low findings Batch 2
Reflects resolution of Tests-007..012, Worker.Tests-008..015,
IntegrationTests-007..010, Client.Python-001/002/004/006/007/008/010/011/012.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:59:35 -04:00
Joseph Doherty a7bf1ef95d Resolve Client.Python-001/002/004/006/007/008/010/011/012 findings
Client.Python-001: dropped "scaffold" from the stale pyproject description.
Client.Python-002 (re-triaged): stale finding — MxGatewayCommandError is
already exported and in __all__; no change needed.
Client.Python-004: removed the dead `closed` variable in _smoke; the CLI
smoke now uses `async with session`.
Client.Python-006: close() on both clients and Session had an unlocked
check-then-set race; `_closed` is now set before the await.
Client.Python-007: gateway stream iterators now share one helper that
explicitly catches CancelledError and cancels the call.
Client.Python-008: to_mx_value now rejects nan/inf; float/bytes mapping
documented.
Client.Python-010: removed the circular-import-workaround late imports in
favour of TYPE_CHECKING / module-scope imports.
Client.Python-011: ensure_mxaccess_success no longer treats a proto3-default
success==0 with an unset category as a failure.
Client.Python-012 (Won't Fix): invoke_raw deliberately skips MXAccess-failure
detection for parity tests; documented the contract instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:59:24 -04:00
Joseph Doherty b4f5e8eb48 Resolve IntegrationTests-007..010 code-review findings
IntegrationTests-007: the three live test classes contend for shared
singletons (one MXAccess COM, one ZB SQL DB, one GLAuth). Added
LiveResourcesCollection with DisableParallelization and applied it to all
three so they no longer run concurrently.

IntegrationTests-008: the three live fact attributes each re-implemented the
env-var check. Added IntegrationTestEnvironment.IsEnabled and all three now
delegate to it.

IntegrationTests-009: reworded the misleading "Mock server call context" XML
doc — it is a hand-written stub with no verification behavior.

IntegrationTests-010: WaitForMessageAsync ignored cancellation. It now takes
an optional CancellationToken linked with the timeout; the smoke test shares
one cancellation source with the StreamEvents call context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:59:18 -04:00
Joseph Doherty 371bcb3f91 Resolve Worker.Tests-008..015 code-review findings
Worker.Tests-008: moved the misplaced WorkerLogRedactor test out of
VariantConverterTests into Bootstrap/WorkerLogRedactorTests.

Worker.Tests-009: renamed 46 snake_case alarm-test methods to PascalCase
Method_Scenario_Expectation.

Worker.Tests-010: replaced a weak Assert.Contains with an exact assertion
against the real diagnostic message and corrected the XML doc.

Worker.Tests-011: renamed and re-documented a cancellation test that
overstated what it proved.

Worker.Tests-012: added an oversized-frame (MessageTooLarge) test; renamed
the mislabeled zero-length-payload test.

Worker.Tests-013: removed the fixed-100ms ThrowIfCompletedAsync helper; the
caller now races runTask deterministically.

Worker.Tests-014: consolidated duplicated test fakes/helpers
(FakeRuntimeSession, NoopComApartmentInitializer, NoopEventSink, frame
helpers) into a shared TestSupport namespace.

Worker.Tests-015: added MxAccessEventQueue coverage for drain-all (maxEvents
0), empty-queue drain, and enqueue-after-fault.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:59:07 -04:00
Joseph Doherty 9582de077b Resolve Tests-007..012 code-review findings
Tests-007: TestServerCallContext and stream-writer/constraint helpers were
copy-pasted across five test files. Consolidated into a shared
MxGateway.Tests.TestSupport namespace; duplicates deleted.

Tests-008: renamed snake_case alarm-test methods to PascalCase
Method_Condition_Result and dropped redundant usings. Re-triaged two
inaccurate sub-claims (the "wnwrap" name and a required CompilerServices
using).

Tests-009: corrected three copy-paste-mismatched XML <summary> comments in
SessionManagerTests.

Tests-010: added the missing anonymous-localhost security negatives —
bypass disallowed, and loopback-allowed from a remote address.

Tests-011: SessionWorkerClientFactoryFakeWorkerTests discarded worker tasks.
The test class now tracks each launcher and observes its task in DisposeAsync.

Tests-012: added xunit.runner.json pinning collection parallelism and
documented the ephemeral-port convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:59:01 -04:00
Joseph Doherty bd3096533d Regenerate code-reviews index after Low findings Batch 1
Reflects resolution of Server-007..014, Worker-009..015,
Client.Dotnet-004..008, Client.Go-004..010, Client.Java-006..012.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:43:02 -04:00
Joseph Doherty 6eb9ea9105 Resolve Client.Java-006..012 code-review findings
Client.Java-006: close() on both clients only called shutdown(). It now
awaits termination up to the connect timeout and shutdownNow()s on timeout.

Client.Java-007: added MxGatewayLowFindingsTests covering the alarm surface,
async streaming, MxEventStream overflow, and TLS channel construction. A
latent bug surfaced: a missing CA file throws IllegalArgumentException, not
SSLException — the channel-builder catch was broadened accordingly.

Client.Java-008: async thenApply sites now route stray RuntimeExceptions
through MxGatewayErrors.fromGrpc via a normalising validator.

Client.Java-009: extracted ~80 duplicated lines (createChannel, withDeadline,
toCompletable, ...) into a shared MxGatewayChannels; both clients delegate.

Client.Java-010 (re-triaged): the README's metadata:read scope was correct;
the acknowledgeAlarm Javadoc's invoke:alarm-ack was wrong — corrected to the
admin scope.

Client.Java-011: documented the intentional fail-fast event-stream
backpressure in Javadoc and the README.

Client.Java-012: replaced CommonOptions.resolved()'s mutate-and-return-this
with side-effect-free resolvedApiKey()/resolvedTimeout() accessors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:42:51 -04:00
Joseph Doherty 555fe4c0ba Resolve Client.Go-004..010 code-review findings
Client.Go-004: ran gofmt on alarms_test.go and galaxy_test.go; the tree is
now gofmt-clean.

Client.Go-005/009/010: migrated Dial/DialGalaxy off the deprecated
grpc.DialContext/WithBlock to grpc.NewClient via a shared dial helper, with
a DialTimeout-bounded readiness probe to keep fail-fast semantics; shared
callContext deadline arithmetic; updated the stale Dial doc comment. Test
harnesses use passthrough:///bufnet for the NewClient default-scheme change.

Client.Go-006: added GatewayError.Code() and an IsTransient(err) helper so
callers can classify transient gRPC failures.

Client.Go-007: newCorrelationID no longer returns an empty id when
crypto/rand fails — it falls back to a non-empty time+counter id.

Client.Go-008: added coverage_test.go for transport-credential resolution,
callContext deadline arithmetic, and native value/array edge kinds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:42:33 -04:00
Joseph Doherty 89043cb2b6 Resolve Client.Dotnet-004..008 code-review findings
Client.Dotnet-004: documented DefaultCallTimeout as both the per-attempt
deadline and the shared retry budget, and removed DeadlineExceeded from the
transient-retry set (a client-imposed deadline cannot be helped by retrying).

Client.Dotnet-005: RegisterAsync/AddItemAsync/AddItem2Async silently returned
0 when a successful reply lacked the typed payload. They now throw a
descriptive MxGatewayException.

Client.Dotnet-006: added XML docs to the previously undocumented public
members MaxGrpcMessageBytes, GatewayProtocolVersion, WorkerProtocolVersion.

Client.Dotnet-007: corrected the AcknowledgeAlarmAsync XML comment — the RPC
requires the admin scope, not a non-existent invoke:alarm-ack sub-scope.

Client.Dotnet-008: the CLI redactor missed env-var-sourced keys because the
caller passed only the --api-key option. Redaction now uses the same
resolver, stripping env-var keys too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:42:27 -04:00
Joseph Doherty 1764eff1cf Resolve Worker-009..015 code-review findings
Worker-009: WorkerFrameWriter serialized twice and WorkerFrameReader
allocated a payload byte[] per frame. The writer now serializes once into a
single prefix+payload buffer; the reader rents the payload buffer from
ArrayPool and honors the logical frame length.

Worker-010: VariantConverter projected a uint+Time value as a full FILETIME,
producing a near-1601 timestamp. The FILETIME projection is now gated on
`value is long`; uint falls through to the integer projection.

Worker-011: replaced the opaque retryAttempts formula in WorkerPipeClient
with MaxRetryAttempts = int.MaxValue, leaving the connect deadline as the
sole bound.

Worker-012: rewrote stale "future PR / polls on a Timer" comments in
AlarmDispatcher, AlarmCommandHandler, MxAccessAlarmEventSink and
MxAccessEventMapper to match the shipped, post-Worker-001 behavior.

Worker-013 (re-triaged): already resolved — StaMessagePumpTests and
MxAccessStaSessionTests cover the pump and poll loop directly.

Worker-014: moved IAlarmCommandHandler into its own file so
AlarmCommandHandler.cs declares one public type.

Worker-015: clarified the MxAccessBaseEventSink.EnqueueEvent overflow-catch
comment explaining the deliberate double RecordFault no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:42:17 -04:00
Joseph Doherty fe9044115b Resolve Server-007..014 code-review findings
Server-007: GalaxyHierarchyProjector re-filtered the whole hierarchy per
page (O(total) paging). It now memoizes the filtered list per cache-entry +
filter signature so subsequent pages are an O(pageSize) slice.

Server-008: WatchDeployEvents re-resolved browse subtrees and rebuilt globs
per streamed event. ResolveBrowseSubtrees is hoisted out of the loop and
GalaxyGlobMatcher caches compiled Regex instances per pattern.

Server-009: auth-store connections used no busy timeout or WAL. A new
OpenConnectionAsync applies journal_mode=WAL and a busy_timeout; all auth
call sites use it. docs/Authentication.md updated.

Server-010: the dashboard rendered Rotate/Revoke for revoked keys, where
Rotate silently reactivates them. ApiKeysPage now shows actions only for
Active keys. docs/Authentication.md updated.

Server-011: WorkerAlarmRpcDispatcher converted to a primary constructor and
brought in line with module conventions.

Server-012: CLAUDE.md corrected to the canonical *:* scope strings.

Server-013 (partly re-triaged): three named coverage gaps were already
closed; the genuine gap (WorkerExecutableValidator) is now covered.

Server-014: rewrote stale "alarm path not yet wired" comments in
MxAccessGatewayService to describe the production WorkerAlarmRpcDispatcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:42:06 -04:00
Joseph Doherty a02faa6ade Regenerate code-reviews index after Medium findings Batch C
Reflects resolution of Contracts-002 — all Medium findings now closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:51:03 -04:00
Joseph Doherty 1f546c46ee Resolve Contracts-002 code-review finding
MxCommandReply.payload has no by-name ack case: MX_COMMAND_KIND_ACKNOWLEDGE_
ALARM_BY_NAME reuses the acknowledge_alarm reply payload. Verified the worker
(MxAccessCommandExecutor.ExecuteAcknowledgeAlarmByName) and gateway
(WorkerAlarmRpcDispatcher) already implement this correctly — the gap was
purely undocumented contract asymmetry. Documented the reuse on the proto
oneof case and the AcknowledgeAlarmReplyPayload message comment (regenerating
the .NET contract), and in docs/AlarmClientDiscovery.md. Added
ProtobufContractRoundTripTests.MxCommandReply_AcknowledgeAlarmByName_Reuses
AcknowledgeAlarmPayloadCase to pin the contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:50:57 -04:00
Joseph Doherty 6a4833bd32 Regenerate code-reviews index after Medium findings Batch B
Reflects resolution of Tests-003..006, Worker.Tests-003..007,
IntegrationTests-003..006, Client.Python-003/005/009.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:45:29 -04:00
Joseph Doherty e4fbbb541a Resolve Client.Python-003, -005, -009 code-review findings
Client.Python-003: stream_events_raw and query_active_alarms passed `timeout`
to the stub with no TypeError fallback, unlike _unary. Both now route through
a shared _open_stream helper that strips `timeout` on TypeError.

Client.Python-005: discover_hierarchy buffered the entire Galaxy hierarchy in
memory. Added GalaxyRepositoryClient.iter_hierarchy, a lazy async generator
yielding objects page-by-page; discover_hierarchy is now a thin wrapper that
preserves its list contract. README documents iter_hierarchy.

Client.Python-009: added regression coverage for previously untested paths —
write2/add_item2 request shape, the MAX_BULK_ITEMS boundary, the None-argument
TypeError guards, TLS ca_file reading, and the non-auth map_rpc_error fallthrough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:45:16 -04:00
Joseph Doherty f13f35bc79 Resolve IntegrationTests-003..006 code-review findings
IntegrationTests-003: the live MXAccess smoke test asserted on the first
streamed event, which a registration/quality bootstrap event could occupy.
The recording writer now waits for the first event matching a predicate
(Family == OnDataChange).

IntegrationTests-004: the cleanup `finally` could throw and mask an original
assertion failure. Shutdown now routes through a helper that logs cleanup
exceptions instead of propagating them.

IntegrationTests-005: added live MXAccess parity tests — a Write round-trip
to an advised item, and an invalid-handle command surfacing the MXAccess
failure without a transport fault.

IntegrationTests-006: added live LDAP failure-path tests — wrong password
(no password leak), unknown username, and server-unreachable.

docs/GatewayTesting.md updated to describe the new cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:45:11 -04:00
Joseph Doherty 18ce2922e2 Resolve Worker.Tests-003..007 code-review findings
Worker.Tests-003: removed the wall-clock `Elapsed < 2s` assertion from
InvokeAsync_WakesIdlePumpForQueuedCommand; the awaited completion against a
30s idle period already proves the wake event drove dispatch.

Worker.Tests-004: MxAccessStaSession.Dispose now joins the alarm poll task
after cancelling the CTS (consistent with ShutdownGracefullyAsync), and
Dispose_StopsAlarmPollLoop asserts deterministically instead of via Task.Delay.

Worker.Tests-005: undisposed MemoryStream instances across the frame-protocol
and pipe-session tests are now `using` declarations.

Worker.Tests-006: Dispose_StopsAlarmPollLoop now constructs MxAccessStaSession
with `using` so a failed assertion cannot leak the STA poll loop.

Worker.Tests-007: docs/WorkerFrameProtocol.md verification section corrected
to target MxGateway.Worker.Tests / MxGateway.Worker with -p:Platform=x86.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:45:01 -04:00
Joseph Doherty 5ade3f4f48 Resolve Tests-003, -004, -005, -006 code-review findings
Tests-003: temp auth-DB directories leaked under %TEMP%. Added the
TempDatabaseDirectory IDisposable helper (clears the Sqlite connection pool,
then recursively deletes); SqliteAuthStoreTests and ApiKeyAdminCliRunnerTests
now dispose every directory they create.

Tests-004: added end-to-end coverage composing the real authorization
interceptor in front of the real MxAccessGatewayService, plus scope-resolver
tests confirming an unmapped request type fails closed to the admin scope.

Tests-005: added coverage for a worker faulting mid-command — a pipe
disconnect and a worker fault while an InvokeAsync is in flight both fail the
pending invoke. No product change needed.

Tests-006 (re-triaged): the flaky ReadLoop_WhenClientFaults_KillsOwnedWorkerProcess
is a test race, not a product bug — the kill runs synchronously inside
SetFaulted. Rewrote it to await FakeWorkerProcess exit deterministically, and
replaced fixed Task.Delay timing in the late-reply and heartbeat tests with
FIFO ordering and an injected ManualTimeProvider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:44:55 -04:00
Joseph Doherty 98f9b7792b Regenerate code-reviews index after Medium findings Batch A
Reflects resolution of Server-002/004/005/006, Worker-004..008,
Client.Dotnet-001/002/003, Client.Go-002/003, Client.Java-001..005.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:32:02 -04:00
Joseph Doherty ff41556b9a Resolve Client.Java-001..005 code-review findings
Client.Java-001: redactApiKey echoed the last 4 secret characters. It now
keeps only the non-secret mxgw_<key-id>_ prefix plus ***; non-gateway-shaped
tokens return <redacted>.

Client.Java-002: a close() after a queue-overflow could wipe the enqueued
overflow exception. Terminal transitions are now serialized through a single
guarded terminate() — first terminal condition wins.

Client.Java-003: openSession never read gateway_protocol_version. Both
openSession paths now call ensureGatewayProtocolCompatible, rejecting a
non-zero mismatch and accepting unset (0) for older gateways.

Client.Java-004: register/addItem/addItem2 fell back to a return_value that
silently yields 0 when unset. The fallback is now guarded by hasReturnValue()
and throws on a protocol violation.

Client.Java-005: close() in try-with-resources could mask the body exception
when the CloseSession RPC failed. close() now catches and logs the
close-time failure; closeRaw() still surfaces it for callers that want it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:31:46 -04:00
Joseph Doherty f88a029ecc Resolve Client.Go-002, -003 code-review findings
Client.Go-002: the Events/EventsAfter compatibility path silently dropped
events when the 16-slot results channel filled — it cancelled the stream and
closed the channel with no error delivered. sendEventResult now evicts an
old buffered event and delivers a terminal EventResult carrying the new
exported ErrEventBufferOverflow before close, so the overflow is observable.

Client.Go-003: parseInt32List panicked on a malformed -item-handles token,
crashing the CLI with a stack trace. It now returns an error that
runUnsubscribeBulk propagates, exiting 2 with a clean message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:31:36 -04:00
Joseph Doherty 8023eccfa6 Resolve Client.Dotnet-001, -002, -003 code-review findings
Client.Dotnet-001: MapRpcException typed only Unauthenticated and
PermissionDenied; every other gRPC status collapsed to an untyped exception
with the status code discarded. Added a nullable StatusCode to
MxGatewayException, extracted the duplicated mappers into a shared
RpcExceptionMapper that records the code for every status, and documented it.

Client.Dotnet-002: the production retry branch (MxGatewayException wrapping
RpcException) was never exercised. FakeGatewayTransport gained a
MapTransportExceptions mode that runs thrown RpcExceptions through
RpcExceptionMapper exactly as the production transport does.

Client.Dotnet-003: MxGatewaySession.DisposeAsync disposed _closeLock while a
concurrent CloseAsync could be parked in WaitAsync. DisposeAsync now drains
in-flight CloseAsync callers before disposing the semaphore; the client's
_disposed flag is accessed via Interlocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:31:33 -04:00
Joseph Doherty 54325343bd Resolve Worker-004, -005, -006, -007, -008 code-review findings
Worker-004: post-watchdog-fault heartbeats reported a non-faulted state.
ReportWatchdogFaultIfNeededAsync now sets _state = Faulted before writing
the StaHung fault.

Worker-005 (re-triaged): the cited OnPoll site was removed by Worker-001;
the real silent-failure bug was in MxAccessStaSession.RunAlarmPollLoopAsync,
which caught only graceful-stop exceptions. A failing PollOnce now records a
WorkerFault on the event queue instead of vanishing on a non-awaited task.

Worker-006: RunAsync's finally skipped runtime disposal when shutdown timed
out, leaking the STA thread and COM object. It now always disposes
(MxAccessStaSession.Dispose is idempotent and bounded).

Worker-007 (re-triaged): replaced MxAccessComServer's Type.InvokeMember
reflection fallback with an IMxAccessServer fast path plus typed
ILMXProxyServer* casts; a non-conforming object now fails fast.

Worker-008: alarm consumer STA affinity was unenforced. MxAccessStaSession
records the alarm consumer's STA thread id and asserts every PollOnce runs
on it via a unit-testable guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:31:23 -04:00
Joseph Doherty 1d9e3afadd Resolve Server-002, -004, -005, -006 code-review findings
Server-002: the gateway never terminated leftover MxGateway.Worker.exe
processes at startup, contradicting gateway.md and CLAUDE.md. Added
IRunningProcessInspector/SystemRunningProcessInspector, OrphanWorkerTerminator,
and OrphanWorkerCleanupHostedService (best-effort, runs before sessions are
accepted); updated gateway.md to describe the implemented behavior.

Server-004: API-key scopes were persisted verbatim with no validation. Added
GatewayScopes.All/IsKnown; the CLI parser and dashboard create path now
reject unknown scope strings.

Server-005: a non-SqlException/InvalidOperationException fault on the initial
Galaxy hierarchy load faulted the BackgroundService. ExecuteAsync now catches
all non-cancellation exceptions on first load and RefreshCoreAsync broadens
its catch so the cache records Stale/Unavailable instead.

Server-006: OpenSessionAsync incremented the open-sessions gauge before
alarm auto-subscribe; an auto-subscribe failure leaked the gauge. The catch
path now calls SessionRemoved() when the gauge was incremented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:31:10 -04:00
Joseph Doherty 5e795aeeb8 Regenerate code-reviews index after High/Critical resolution batch
Reflects the resolution of Tests-001/002, IntegrationTests-001/002,
Client.Go-001, Worker-001/002/003 and Worker.Tests-001/002.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:08:06 -04:00
Joseph Doherty 1b4dcf32d5 Resolve Worker.Tests-001 and Worker.Tests-002 code-review findings
Worker.Tests-001: StaMessagePump had no direct unit test. Added
Sta/StaMessagePumpTests.cs — 8 STA-thread facts covering WaitForWorkOrMessages
(wake-event signalled before/during the wait, timeout expiry, zero-timeout
fast path, the QS_ALLINPUT posted-message wake path) and PumpPendingMessages
drain counting.

Worker.Tests-002: no test drove a COM event through the integrated
sink -> mapper -> queue path. Added MxAccess/MxAccessBaseEventSinkTests.cs —
5 facts driving OnDataChange, OnWriteComplete, OperationComplete and
OnBufferedDataChange through a real MxAccessBaseEventSink + mapper + queue and
asserting the converted WorkerEvent lands in MxAccessEventQueue. The four COM
event handlers were widened private -> internal and InternalsVisibleTo for
MxGateway.Worker.Tests was added, mirroring MxAccessAlarmEventSink's existing
test seam; no worker behavior changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:07:48 -04:00
Joseph Doherty 53e3973209 Resolve Worker-001, Worker-002, Worker-003 code-review findings
Worker-001: WnWrapAlarmConsumer armed a System.Threading.Timer whose OnPoll
callback ran GetXmlCurrentAlarms2 on a thread-pool thread against the
Apartment-threaded wnwrap COM object, which can deadlock on cross-apartment
marshaling. Removed the pollTimer/pollIntervalMs fields, OnPoll, the
poll-interval constructor parameter, and the timer arm/disposal. Polls are
driven externally by the STA via StaRuntime.InvokeAsync(PollOnce).

Worker-002: RunHeartbeatLoopAsync delayed a full HeartbeatInterval before
the first heartbeat. Restructured so the first beat is sent immediately on
entering the loop and the delay applies only between subsequent beats.

Worker-003: ProcessCommandAsync silently returned without a reply when
_state was not a command-serving state after dispatch. Both drop sites now
log a WorkerCommandResultDropped diagnostic with correlation_id via
IWorkerLogger; _state is now volatile.

Three pre-existing tests that asserted strict frame ordering were updated to
tolerate an interleaved first heartbeat (Worker-002 consequence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:59:46 -04:00
Joseph Doherty e967e85973 Resolve Client.Go-001 code-review finding
MxAccessError.Unwrap returned e.Command directly; on the HRESULT-only path
Command is a nil *CommandError, so Unwrap returned a non-nil error wrapping
a typed nil and errors.As bound a nil *CommandError. Unwrap now returns an
untyped nil when Command is nil. Added errors_test.go regression coverage
for the HRESULT-only and populated-Command paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:46:12 -04:00
Joseph Doherty bc55396334 Resolve IntegrationTests-001 and IntegrationTests-002 code-review findings
IntegrationTests-001: documented the live Galaxy Repository test suite and
its MXGATEWAY_RUN_LIVE_GALAXY_TESTS / MXGATEWAY_LIVE_GALAXY_CONN gating in
docs/GatewayTesting.md.

IntegrationTests-002: documented the live LDAP test suite in
docs/GatewayTesting.md and added a concrete "Provisioning the GwAdmin group"
step to glauth.md so DashboardLdapLiveTests' GwAdmin-membership assumption
is reproducible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:46:09 -04:00
Joseph Doherty b381bfcaf1 Resolve Tests-001 and Tests-002 code-review findings
Tests-001: FakeSessionManager.TryGetSession unconditionally synthesized a
session, so Invoke_WhenSessionMissing_ThrowsNotFound did not actually
verify the missing-session path. Added ResolveOnlySeededSessions/SeedSession
to the fake, rewrote the missing-session test, and added seeded-resolution
and alarm-RPC missing-session coverage.

Tests-002: re-triaged. GalaxyRepository issues only constant SQL; filters
are applied in-memory by GalaxyHierarchyProjector/GalaxyGlobMatcher. Kept
as a valid coverage gap and added GalaxyFilterInputSafetyTests exercising
filter/glob input safety directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:46:02 -04:00
Joseph Doherty 2a635c8522 Add code-reviews/prompt.md orchestration prompt
Reusable prompt for working the code-reviews/ backlog: batches one
subagent per module, TDD per finding, per-module commits, regenerates
the index. Adapted to mxaccessgw toolchains and module layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:18:39 -04:00
Joseph Doherty 9082e504a9 Mark Client.Rust findings resolved
All eleven Client.Rust findings are fixed in 0d8a28d; their Status is
now Resolved with the fixing commit recorded. Adds Client.Rust-012 —
an additional clippy::clone_on_copy violation in galaxy.rs found while
verifying that `cargo clippy -- -D warnings` passes — already Resolved
in the same commit. Regenerates code-reviews/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:10:30 -04:00
Joseph Doherty 0d8a28d2fe Fix all MxGateway.Client.Rust code-review findings
Resolves Client.Rust-001 through Client.Rust-011.

Build/test/clippy gate (Client.Rust-001/002/003):
- options.rs: doc comments on with_max_grpc_message_bytes /
  max_grpc_message_bytes (#![warn(missing_docs)])
- session.rs: rename BulkReplyKind variants to drop the shared `Bulk`
  suffix (clippy::enum_variant_names)
- galaxy.rs: deref instead of clone on Option<Timestamp>
  (clippy::clone_on_copy — an extra violation the gate also hit)
- mxgw-cli: assert version_json against GATEWAY/WORKER_PROTOCOL_VERSION
  constants instead of the stale literal 2
`cargo clippy --workspace --all-targets -- -D warnings` now passes.

Correctness / error handling:
- version.rs: CLIENT_VERSION = env!("CARGO_PKG_VERSION") (Client.Rust-004)
- session.rs: register/add_item/add_item2 handle extractors and
  bulk_results now return Err(Error::MalformedReply) instead of a
  silent 0 / empty vec on a shapeless OK reply (Client.Rust-005/006)
- error.rs: new Error::Unavailable classifies Code::Unavailable /
  ResourceExhausted as transient (Client.Rust-010)
- session.rs: per-call unique correlation ids via an atomic counter
  (Client.Rust-011)

Other:
- value.rs: MxValue/MxArrayValue compute the projection on demand
  instead of caching it, so a wire-only value pays no projection cost
  (Client.Rust-008)
- RustClientDesign.md: correct the crate layout, drop the unused
  `tracing` dependency (Client.Rust-007)
- client_behavior.rs: tests for the bulk-size cap, a mid-stream status
  fault, and the unreadable-CA-file path (Client.Rust-009)

cargo fmt / test --workspace (27 tests) / clippy all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:08:55 -04:00
Joseph Doherty f0a4af62b9 Review the clients/ language clients; mark Server-001/003 resolved
Adds per-module code reviews for the five language clients under
clients/ (Client.Dotnet, Client.Go, Client.Java, Client.Python,
Client.Rust) at commit 3cc53a8 — 53 findings (4 High, 15 Medium,
34 Low; all Open). Extends REVIEW-PROCESS.md so a "module" may also be
a language client under clients/, not only a src/ project.

Marks Server-001 (Critical) and Server-003 (High) Resolved — fixed in
a8aafdf — and regenerates code-reviews/README.md (now 11 modules).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:51:00 -04:00
Joseph Doherty a8aafdf974 Enforce dashboard authorization on all component routes
Fixes code-review findings Server-001 (Critical) and Server-003 (High).

Server-001: the dashboard Razor components were mapped with no
authorization policy, so every dashboard page — including the API Keys
page — was reachable unauthenticated. MapRazorComponents<App>() now
requires DashboardAuthenticationDefaults.AuthorizationPolicy;
unauthenticated requests are challenged by the cookie scheme and
redirected to the login page.

Server-003: DashboardAuthenticator.CreatePrincipal never issued the
'scope' claim that DashboardAuthorizationHandler checks when
Dashboard:RequireAdminScope is enabled, so enforcing the policy would
have denied every LDAP login. CreatePrincipal (reached only after the
required-group check passes) now emits the admin scope claim.

Replaces the GatewayApplicationTests case that asserted dashboard
routes allow anonymous access — it encoded the bug as expected
behavior — with tests that verify component routes require the policy
and the login/logout/denied endpoints allow anonymous.

All 309 MxGateway.Tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:45:29 -04:00
Joseph Doherty 3cc53a8c69 Harden code-review tooling and align REVIEW-PROCESS.md with mxaccessgw
- regen-readme.py: use `python` not the broken `python3` Store alias in
  the generated note and docstring; --check now also fails when a module
  header's "Open findings" count disagrees with finding statuses or a
  finding has an unrecognised Status (find_inconsistencies)
- REVIEW-PROCESS.md: rewritten for mxaccessgw (was describing ScadaLink)
  — MxGateway.* modules, "mxaccessgw conventions" checklist category,
  gateway.md/docs/ design context, `python` command
- scripts/check-code-reviews-readme.ps1: CI/pre-commit wrapper for
  regen-readme.py --check
- code-reviews/test_regen_readme.py: dependency-free parser tests
- code-reviews/README.md: regenerated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:36:25 -04:00
Joseph Doherty ae164ea34f Add per-module code review tree under code-reviews/
Set up the code review process scaffolding adapted to mxaccessgw and
record a full per-module review of every src/MxGateway.* project at
commit 6c64030.

- code-reviews/_template/findings.md: per-module findings template
- code-reviews/regen-readme.py: generates README.md from findings.md
  files; --check fails if stale
- code-reviews/<Module>/findings.md: reviews for Contracts, Server,
  Worker, Tests, Worker.Tests, IntegrationTests (74 findings:
  1 Critical, 10 High, 23 Medium, 40 Low; all Open)
- code-reviews/README.md: generated cross-module index
- REVIEW-PROCESS.md: review process document

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:28:50 -04:00
Joseph Doherty 6c640306e5 Merge branch 'fix/alarm-sta-wiring'
Close the two alarm production gaps: wire alarmCommandHandlerFactory in
WorkerPipeSession, and drive WnWrapAlarmConsumer.PollOnce from the STA
instead of a threadpool timer.
2026-05-18 06:31:05 -04:00
Joseph Doherty a67a5a4857 fix(worker): wire alarm command handler and STA poll loop (Gap 1 + Gap 2)
Gap 1 — WorkerPipeSession now passes `eq => new AlarmCommandHandler(eq)` as
the alarmCommandHandlerFactory in all three places it constructs
MxAccessStaSession (two convenience constructors and InitializeMxAccessAsync).
Previously the parameterless MxAccessStaSession() set the factory to null,
so every SubscribeAlarms / AcknowledgeAlarm / QueryActiveAlarms command
returned "alarm consumer not configured" in a deployed worker.

  - Added internal `MxAccessStaSession(Func<MxAccessEventQueue, IAlarmCommandHandler>?)`
    constructor that builds all defaults but accepts a factory.
  - Added public `MxAccessStaSession(StaRuntime, factory, eventQueue, alarmFactory?)`
    4-arg overload to complete the constructor chain.

Gap 2 — WnWrapAlarmConsumer now disables its internal threadpool Timer
(pollIntervalMilliseconds=0 in the default constructor). MxAccessStaSession
starts a `RunAlarmPollLoopAsync` background task that sleeps off-STA then
calls `staRuntime.InvokeAsync(() => handler.PollOnce())` at 500ms intervals.
This satisfies the ThreadingModel=Apartment requirement of wwAlarmConsumerClass:
every GetXmlCurrentAlarms2 call now runs on the worker's STA.

  - Added `PollOnce()` to `IMxAccessAlarmConsumer`, `AlarmDispatcher`,
    `IAlarmCommandHandler`, and `AlarmCommandHandler`.
  - Poll loop cancelled and awaited before alarm handler disposal in both
    ShutdownGracefullyAsync and Dispose.

Tests: 4 new tests in MxAccessStaSessionTests verify that
  - SubscribeAlarms reaches the handler when the factory is wired (Gap 1)
  - SubscribeAlarms returns InvalidRequest without a factory (regression guard)
  - PollOnce is called on the STA thread within 3s (Gap 2)
  - The poll loop stops after Dispose (Gap 2 lifecycle)
All fake IMxAccessAlarmConsumer / IAlarmCommandHandler test implementations
updated with no-op PollOnce() to satisfy the new interface member.

Worker tests: 199 passed / 1 pre-existing failure / 4 skipped (was 195/1/4).
Server tests: 308 passed / 0 failures (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 06:30:14 -04:00
Joseph Doherty e00ee61cf0 Place Last Refresh next to Last Deploy on the Galaxy page
Group the two double-width timestamp cards at the start of the metric
row so the deploy/refresh pair reads together, ahead of the count cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:20:30 -04:00
Joseph Doherty 271bf7edff Give Galaxy timestamp cards double-width boxes
The Last Deploy and Last Refresh metric cards hold full timestamps that
wrapped to three or four lines in a single-width card. Add a Wide option
to MetricCard (grid-column: span 2) and set it on both Galaxy timestamp
cards. Also switch .metric-value to overflow-wrap: break-word so a date
token is never split mid-value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:16:56 -04:00
Joseph Doherty 3397e99783 Document the dashboard API Keys management page
The dashboard's API Keys page (list plus Create/Rotate/Revoke and the
create dialog) had no design-doc coverage even though Authorization.md
already documents the constraint model it exposes. Add an "API keys
page" section to GatewayDashboardDesign.md describing the table columns,
the LDAP-group-gated management actions, the one-time secret reveal, and
audit logging. Cross-link it from the constraint-enforcement section of
Authorization.md and the CLI section of Authentication.md so the two
key-management surfaces reference each other.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:07:35 -04:00
Joseph Doherty f598b3a647 Stop dashboard table cells breaking whole words
overflow-wrap: anywhere let the table layout shrink a column below its
longest word, splitting tokens like "unconstrained" mid-word. Switch to
break-word so a word only breaks when it genuinely cannot fit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:56:55 -04:00
Joseph Doherty 509b0118d4 Keep dashboard button labels on a single line
Bootstrap 5 .btn no longer pins white-space, so the Rotate/Revoke action
buttons broke mid-word in the narrow API Keys actions column. Pin .btn
to white-space: nowrap so a label always reads as one word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:54:53 -04:00
Joseph Doherty 298836d2f3 Keep dashboard status chips on a single line
Status chips wrapped to two lines in narrow table columns (e.g. the
session State column). Pin .chip to white-space: nowrap so an enumerated
state always reads as one token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:19:36 -04:00
Joseph Doherty 96bea1d478 Apply technical-light design system to the gateway dashboard
Restyles the Blazor dashboard onto a portable token-based theme so it
reads like an instrument panel: warm-paper background, hairline-ruled
panels, IBM Plex type, monospace tabular numerics, and status carried by
colour chips. Vendors theme.css + IBM Plex fonts, rewrites dashboard.css
as a thin token-driven view layer, and swaps the Bootstrap navbar and
status badges for the design-system app bar and chips.

Also includes pending API-key management, Galaxy hierarchy projection,
and constraint-enforcement work with their tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:31:04 -04:00
756 changed files with 15425 additions and 39096 deletions
-1
View File
@@ -45,7 +45,6 @@ build/
out/
tmp/
temp/
install/
# .NET
**/bin/
+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
# API-key admin CLI (same exe, "apikey" subcommand)
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session,invoke,event,metadata,admin
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
```
Single test by name (xUnit `--filter`):
@@ -114,9 +114,9 @@ External analysis sources referenced by design docs:
## Authentication
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`.
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`.
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.
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.
## Process / Platform Notes
+5 -5
View File
@@ -3,15 +3,15 @@
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/ZB.MOM.WW.MxGateway.Worker`)
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 `ZB.MOM.WW.MxGateway.`
prefix stripped — `src/ZB.MOM.WW.MxGateway.Server` is reviewed in `code-reviews/Server/`.
- 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:
@@ -65,8 +65,8 @@ means the checklist is completed even where it produces no findings — record
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/ZB.MOM.WW.MxGateway.Tests`, `src/ZB.MOM.WW.MxGateway.Worker.Tests`,
`src/ZB.MOM.WW.MxGateway.IntegrationTests`)? Note untested critical paths and missing
(`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.
+13 -17
View File
@@ -1,21 +1,17 @@
<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>
<!-- Shared package metadata for clients/dotnet/. Individual projects opt in via <IsPackable>true</IsPackable>. -->
<Authors>Joseph Doherty</Authors>
<Company>ZB MOM WW</Company>
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
<Product>MxAccessGateway Client</Product>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
<PackageTags>mxaccess;mxgateway;grpc;client;archestra</PackageTags>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<!-- Versioning: bump per release. Symbols ship as snupkg. -->
<Version>0.1.0</Version>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- Default: do NOT pack. Each project opts in. -->
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<Deterministic>true</Deterministic>
</PropertyGroup>
</Project>
+11 -30
View File
@@ -16,9 +16,9 @@ Recommended layout:
```text
clients/dotnet/
ZB.MOM.WW.MxGateway.Client.slnx
ZB.MOM.WW.MxGateway.Client/
ZB.MOM.WW.MxGateway.Client.csproj
MxGateway.Client.sln
MxGateway.Client/
MxGateway.Client.csproj
GatewayClient.cs
MxGatewaySession.cs
MxGatewayClientOptions.cs
@@ -26,14 +26,14 @@ clients/dotnet/
Conversion/
Errors/
Generated/
ZB.MOM.WW.MxGateway.Client.Cli/
ZB.MOM.WW.MxGateway.Client.Cli.csproj
MxGateway.Client.Cli/
MxGateway.Client.Cli.csproj
Program.cs
Commands/
ZB.MOM.WW.MxGateway.Client.Tests/
ZB.MOM.WW.MxGateway.Client.Tests.csproj
ZB.MOM.WW.MxGateway.Client.IntegrationTests/
ZB.MOM.WW.MxGateway.Client.IntegrationTests.csproj
MxGateway.Client.Tests/
MxGateway.Client.Tests.csproj
MxGateway.Client.IntegrationTests/
MxGateway.Client.IntegrationTests.csproj
```
Target framework:
@@ -43,7 +43,7 @@ Target framework:
```
The scaffold uses a project reference to
`src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` for generated protobuf and
`src/MxGateway.Contracts/MxGateway.Contracts.csproj` for generated protobuf and
gRPC types. `clients/dotnet/generated` remains reserved for client-local
generator output if the .NET client later needs to decouple from the contracts
project.
@@ -107,7 +107,6 @@ public sealed class MxGatewayClientOptions
public required string ApiKey { get; init; }
public bool UseTls { get; init; }
public string? CaCertificatePath { get; init; }
public bool RequireCertificateValidation { get; init; }
public string? ServerNameOverride { get; init; }
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
@@ -125,24 +124,6 @@ or subscription changes because those calls can partially succeed in MXAccess.
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
library constructor unless a helper explicitly says it does that.
### TLS trust posture
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). To make that usable, TLS is **lenient by default**: when `UseTls` is set
and `CaCertificatePath` is empty, `CreateHttpHandler` installs a
`RemoteCertificateValidationCallback` that returns `true`, so the gateway's
self-signed certificate is accepted without verification.
To verify the gateway instead:
- set `CaCertificatePath` to pin a CA — validated via a `CustomRootTrust`
`X509Chain` against that root, and the callback additionally rejects a
hostname/SAN mismatch (`RemoteCertificateNameMismatch`); or
- set `RequireCertificateValidation` to `true` to keep the default OS/system-trust
verification on a connection with no pinned CA.
Pinning a CA always wins over the lenient default.
## Auth Interceptor
Use a gRPC call credentials/interceptor layer to attach:
@@ -185,7 +166,7 @@ reply.EnsureMxAccessSuccess();
## Test CLI
Project: `ZB.MOM.WW.MxGateway.Client.Cli`.
Project: `MxGateway.Client.Cli`.
Command examples:
@@ -1,6 +1,6 @@
using System.Globalization;
namespace ZB.MOM.WW.MxGateway.Client.Cli;
namespace MxGateway.Client.Cli;
/// <summary>Parses command-line arguments into flags and named values.</summary>
internal sealed class CliArguments
@@ -1,8 +1,14 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Cli;
namespace 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
{
/// <summary>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj" />
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
</ItemGroup>
<PropertyGroup>
@@ -1,8 +1,8 @@
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Cli;
namespace MxGateway.Client.Cli;
internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
{
@@ -1,4 +1,4 @@
namespace ZB.MOM.WW.MxGateway.Client.Cli;
namespace MxGateway.Client.Cli;
/// <summary>Utility to redact API keys from error messages for safe output.</summary>
internal static class MxGatewayCliSecretRedactor
@@ -1,11 +1,11 @@
using System.Globalization;
using System.Text.Json;
using Google.Protobuf;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Cli;
namespace MxGateway.Client.Cli;
/// <summary>Command-line interface for the MXAccess Gateway client, supporting session and command operations.</summary>
public static class MxGatewayClientCli
@@ -16,8 +16,6 @@ public static class MxGatewayClientCli
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>
/// <param name="args">Command-line arguments (command name followed by options).</param>
/// <param name="standardOutput">TextWriter for command output.</param>
@@ -57,6 +55,8 @@ public static class MxGatewayClientCli
standardInput ?? Console.In);
}
private const string BatchEndOfRecord = "__MXGW_BATCH_EOR__";
private static async Task<int> RunCoreAsync(
string[] args,
TextWriter standardOutput,
@@ -126,6 +126,8 @@ public static class MxGatewayClientCli
.ConfigureAwait(false),
"bench-read-bulk" => await BenchReadBulkAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"bench-stream-events" => await BenchStreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"stream-alarms" => await StreamAlarmsAsync(arguments, client, standardOutput, cancellation.Token)
@@ -151,7 +153,10 @@ public static class MxGatewayClientCli
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
string? apiKey = arguments.GetOptional("api-key");
// Redact the effective API key — whether it came from --api-key or from
// 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);
if (forceJsonErrors || arguments.HasFlag("json"))
@@ -169,6 +174,88 @@ 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>
/// Runs the CLI in batch mode: reads one command line at a time from
/// <paramref name="standardInput"/>, dispatches it through the normal
@@ -216,13 +303,9 @@ public static class MxGatewayClientCli
forceJsonErrors: true)
.ConfigureAwait(false);
}
catch (Exception exception)
catch (Exception exception) when (exception is not OperationCanceledException)
{
// 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(
new { error = exception.Message, type = exception.GetType().Name },
JsonOptions));
@@ -249,70 +332,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 = 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(
CliArguments arguments,
IMxGatewayCliClient client,
@@ -487,7 +506,7 @@ public static class MxGatewayClientCli
ReadBulkCommand command = new()
{
ServerHandle = arguments.GetInt32("server-handle"),
TimeoutMs = ParseTimeoutMs(arguments, defaultValue: 0),
TimeoutMs = (uint)arguments.GetInt32("timeout-ms", 0),
};
command.TagAddresses.Add(ParseStringList(arguments.GetRequired("items")));
@@ -663,78 +682,6 @@ public static class MxGatewayClientCli
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>
/// Parses the optional <c>--timeout-ms</c> argument as a non-negative
/// unsigned millisecond count. Mirrors the SDK-side <c>(uint)Math.Min</c>
/// guard on <c>MxGatewaySession.ReadBulkAsync</c>: a negative value
/// (e.g. <c>-1</c>, an easy copy-paste mistake for "unbounded") is
/// rejected loudly rather than silently wrapped to <c>~49.7 days</c>,
/// which would park one worker thread per pending tag for hours.
/// Resolves Client.Dotnet-021.
/// </summary>
private static uint ParseTimeoutMs(CliArguments arguments, int defaultValue)
{
int raw = arguments.GetInt32("timeout-ms", defaultValue);
if (raw < 0)
{
throw new ArgumentException(
"--timeout-ms must be a non-negative integer (use 0 for the gateway default).");
}
return (uint)raw;
}
/// <summary>
/// Extracts the <c>ServerHandle</c> from a Register reply, throwing a
/// descriptive <see cref="MxGatewayException"/> when the typed
/// <c>Register</c> payload is absent on an otherwise-successful reply.
/// The typed sub-message is the contract for the Register command, so
/// its absence must not silently fall through to
/// <c>ReturnValue.Int32Value</c> (which would be <c>0</c> for an empty
/// reply, driving the rest of the bench against an invalid handle).
/// Resolves Client.Dotnet-019.
/// </summary>
private static int RequireRegisterServerHandle(MxCommandReply reply, string sessionId)
{
if (reply.Register is null)
{
throw new MxGatewayException(
$"Gateway reply for Register on session '{sessionId}' (correlation '{reply.CorrelationId}') "
+ "succeeded but is missing the typed 'register' payload required to read ServerHandle.");
}
return reply.Register.ServerHandle;
}
/// <summary>
/// Cross-language stress benchmark for ReadBulk. Opens its own session,
/// subscribes to N tags so the worker's MxAccessValueCache populates from
@@ -755,7 +702,7 @@ public static class MxGatewayClientCli
int tagStart = arguments.GetInt32("tag-start", 1);
string tagPrefix = arguments.GetOptional("tag-prefix") ?? "TestMachine_";
string tagAttribute = arguments.GetOptional("tag-attribute") ?? "TestChangingInt";
uint timeoutMs = ParseTimeoutMs(arguments, defaultValue: 1500);
uint timeoutMs = (uint)arguments.GetInt32("timeout-ms", 1500);
string clientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-bench";
string[] tags = new string[bulkSize];
@@ -785,7 +732,7 @@ public static class MxGatewayClientCli
}),
cancellationToken)
.ConfigureAwait(false);
int serverHandle = RequireRegisterServerHandle(registerReply, sessionId);
int serverHandle = RequireRegisterServerHandle(registerReply);
SubscribeBulkCommand subscribe = new() { ServerHandle = serverHandle };
subscribe.TagAddresses.Add(tags);
@@ -844,13 +791,8 @@ public static class MxGatewayClientCli
.ConfigureAwait(false);
sw.Stop();
}
catch (Exception ex) when (ex is not OperationCanceledException)
catch
{
// Client.Dotnet-020: never swallow OperationCanceledException
// here. A bare `catch` would let Ctrl+C / parent CTS /
// wall-clock timeouts keep spinning until --duration-seconds
// elapsed, burning CPU and skewing the p99/max latency numbers
// with hundreds of immediate-OCE iterations.
sw.Stop();
failedCalls++;
latencyMillis.Add(sw.Elapsed.TotalMilliseconds);
@@ -944,6 +886,295 @@ 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>
/// Computes the requested percentile from an unsorted latency sample using
/// nearest-rank with linear interpolation. Rounds to 3 decimal places to
@@ -971,6 +1202,35 @@ public static class MxGatewayClientCli
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(
CliArguments arguments,
IMxGatewayCliClient client,
@@ -1077,8 +1337,14 @@ public static class MxGatewayClientCli
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Client.Dotnet-017: graceful end-of-window completion mode for a
// finite-window event collector. Emit aggregate JSON below and exit 0.
// Client.Dotnet-017: the supplied cancellation token covers both the
// user's --timeout wall-clock budget (via CreateCancellation's
// 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)
@@ -1240,7 +1506,7 @@ public static class MxGatewayClientCli
Kind = MxCommandKind.Register,
Register = new RegisterCommand { ClientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-smoke" },
},
reply => reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value,
RequireRegisterServerHandle,
commandReplies,
cancellationToken)
.ConfigureAwait(false);
@@ -1258,7 +1524,7 @@ public static class MxGatewayClientCli
ItemDefinition = arguments.GetRequired("item"),
},
},
reply => reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value,
RequireAddItemItemHandle,
commandReplies,
cancellationToken)
.ConfigureAwait(false);
@@ -1390,6 +1656,41 @@ public static class MxGatewayClientCli
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(
string sessionId,
MxCommand command)
@@ -1486,12 +1787,12 @@ public static class MxGatewayClientCli
return ParseValue(arguments.GetRequired("type"), arguments.GetRequired("value"));
}
private static MxValue ParseValue(string type, string value)
private static MxValue ParseValue(string typeName, string value)
{
string normalisedType = type.ToLowerInvariant();
string type = typeName.ToLowerInvariant();
string[] values = value.Split(',', StringSplitOptions.TrimEntries);
return normalisedType switch
return type switch
{
"bool" or "boolean" => bool.Parse(value).ToMxValue(),
"bool-array" or "boolean-array" => values.Select(bool.Parse).ToArray().ToMxValue(),
@@ -1510,7 +1811,7 @@ public static class MxGatewayClientCli
.Select(item => DateTimeOffset.Parse(item, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal))
.ToArray()
.ToMxValue(),
_ => throw new ArgumentException($"Unsupported MX value type '{normalisedType}'."),
_ => throw new ArgumentException($"Unsupported MX value type '{type}'."),
};
}
@@ -1727,6 +2028,7 @@ public static class MxGatewayClientCli
or "write-secured-bulk"
or "write-secured2-bulk"
or "bench-read-bulk"
or "bench-stream-events"
or "stream-events"
or "stream-alarms"
or "acknowledge-alarm"
@@ -1780,14 +2082,13 @@ public static class MxGatewayClientCli
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 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 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 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> --current-user-id <n> [--verifier-user-id <n>] [--timestamp <iso>] [--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 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 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 stream-events --session-id <id> [--max-events <n>] [--json]");
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
@@ -1,3 +1,3 @@
using ZB.MOM.WW.MxGateway.Client.Cli;
using MxGateway.Client.Cli;
return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error);
@@ -1,7 +1,7 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
/// <summary>
/// Fake Galaxy Repository client transport for testing.
@@ -48,7 +48,6 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
/// </summary>
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
/// <summary>Gets the queue of discover hierarchy replies; dequeued in FIFO order.</summary>
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
/// <summary>
@@ -123,39 +122,6 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
: DiscoverHierarchyReply);
}
/// <summary>Records BrowseChildren RPC calls made by the client.</summary>
public List<(BrowseChildrenRequest Request, CallOptions CallOptions)> BrowseChildrenCalls { get; } = [];
/// <summary>Default reply returned from BrowseChildren when the queue is empty.</summary>
public BrowseChildrenReply BrowseChildrenReply { get; set; } = new();
/// <summary>Queue of replies returned from BrowseChildren; dequeued in FIFO order.</summary>
public Queue<BrowseChildrenReply> BrowseChildrenReplies { get; } = new();
/// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary>
public Queue<Exception> BrowseChildrenExceptions { get; } = new();
/// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The BrowseChildrenRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions)
{
BrowseChildrenCalls.Add((request, callOptions));
if (BrowseChildrenExceptions.TryDequeue(out Exception? exception))
{
return Task.FromException<BrowseChildrenReply>(exception);
}
return Task.FromResult(
BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply)
? reply
: BrowseChildrenReply);
}
/// <summary>
/// Gets the list of WatchDeployEvents RPC calls made by the client.
/// </summary>
@@ -1,7 +1,7 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
/// <summary>
/// Fake implementation of IMxGatewayClientTransport for testing.
@@ -46,11 +46,6 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// </summary>
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>
/// Gets the list of captured StreamAlarmsAsync calls.
/// </summary>
@@ -63,7 +58,6 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new();
private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = [];
private readonly List<AlarmFeedMessage> _alarmFeedMessages = [];
/// <summary>
/// Gets or sets the reply to return from OpenSessionAsync.
@@ -97,6 +91,19 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// </summary>
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>
/// Gets the queue of exceptions to throw from InvokeAsync.
/// </summary>
@@ -114,7 +121,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
OpenSessionCalls.Add((request, callOptions));
if (OpenSessionExceptions.TryDequeue(out Exception? exception))
{
throw exception;
throw Translate(exception, callOptions);
}
return Task.FromResult(OpenSessionReply);
@@ -125,17 +132,23 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// </summary>
/// <param name="request">The CloseSessionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<CloseSessionReply> CloseSessionAsync(
public async Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CallOptions callOptions)
{
CloseSessionCalls.Add((request, callOptions));
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
if (CloseSessionHook is not null)
{
throw exception;
await CloseSessionHook().ConfigureAwait(false);
}
return Task.FromResult(CloseSessionReply);
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
{
throw Translate(exception, callOptions);
}
return CloseSessionReply;
}
/// <summary>
@@ -150,7 +163,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
InvokeCalls.Add((request, callOptions));
if (InvokeExceptions.TryDequeue(out Exception? exception))
{
throw exception;
throw Translate(exception, callOptions);
}
return Task.FromResult(_invokeReplies.Dequeue());
@@ -196,8 +209,6 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// <summary>
/// Records the acknowledge call and returns the next enqueued reply (or default).
/// </summary>
/// <param name="request">The acknowledge alarm request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CallOptions callOptions)
@@ -205,7 +216,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
AcknowledgeAlarmCalls.Add((request, callOptions));
if (AcknowledgeAlarmExceptions.TryDequeue(out Exception? exception))
{
throw exception;
throw Translate(exception, callOptions);
}
return Task.FromResult(_acknowledgeReplies.Count > 0
@@ -219,61 +230,48 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
}
/// <summary>
/// Records the query call and yields each enqueued snapshot.
/// Records the call and yields each enqueued snapshot as an active-alarm
/// feed message, then a snapshot-complete sentinel.
/// </summary>
/// <param name="request">The query active alarms request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CallOptions callOptions)
{
QueryActiveAlarmsCalls.Add((request, callOptions));
foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots)
{
callOptions.CancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return snapshot;
}
}
/// <summary>Enqueues an acknowledge reply.</summary>
/// <param name="reply">The acknowledge reply to enqueue.</param>
public void AddAcknowledgeReply(AcknowledgeAlarmReply reply)
{
_acknowledgeReplies.Enqueue(reply);
}
/// <summary>Enqueues a snapshot to be yielded from QueryActiveAlarmsAsync.</summary>
/// <param name="snapshot">The snapshot to enqueue.</param>
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
{
_activeAlarmSnapshots.Add(snapshot);
}
/// <summary>
/// Records the stream-alarms call and yields each enqueued feed message.
/// </summary>
/// <param name="request">The stream alarms request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CallOptions callOptions)
{
StreamAlarmsCalls.Add((request, callOptions));
foreach (AlarmFeedMessage message in _alarmFeedMessages)
foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots)
{
callOptions.CancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return message;
yield return new AlarmFeedMessage { ActiveAlarm = snapshot };
}
yield return new AlarmFeedMessage { SnapshotComplete = true };
}
/// <summary>Enqueues an alarm feed message to be yielded from StreamAlarmsAsync.</summary>
/// <param name="message">The alarm feed message to enqueue.</param>
public void AddAlarmFeedMessage(AlarmFeedMessage message)
/// <summary>Enqueues an acknowledge reply.</summary>
public void AddAcknowledgeReply(AcknowledgeAlarmReply reply)
{
_alarmFeedMessages.Add(message);
_acknowledgeReplies.Enqueue(reply);
}
/// <summary>Enqueues a snapshot yielded from StreamAlarmsAsync as an active-alarm message.</summary>
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
{
_activeAlarmSnapshots.Add(snapshot);
}
/// <summary>
/// Maps a queued exception the way the production gRPC transport does when
/// <see cref="MapTransportExceptions"/> is set; otherwise returns it unchanged.
/// </summary>
private Exception Translate(Exception exception, CallOptions callOptions)
{
if (MapTransportExceptions && exception is RpcException rpcException)
{
return RpcExceptionMapper.Map(rpcException, callOptions.CancellationToken);
}
return exception;
}
}
@@ -1,8 +1,8 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
public sealed class GalaxyRepositoryClientTests
{
@@ -181,9 +181,6 @@ public sealed class GalaxyRepositoryClientTests
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
/// </summary>
[Fact]
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
{
@@ -215,9 +212,6 @@ public sealed class GalaxyRepositoryClientTests
Assert.True(request.HistorizedOnly);
}
/// <summary>
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
/// </summary>
[Fact]
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
{
@@ -1,8 +1,8 @@
using Google.Protobuf;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
public sealed class MxCommandReplyExtensionsTests
{
@@ -19,8 +19,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client.Cli\ZB.MOM.WW.MxGateway.Client.Cli.csproj" />
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
<ProjectReference Include="..\MxGateway.Client.Cli\MxGateway.Client.Cli.csproj" />
</ItemGroup>
</Project>
@@ -1,17 +1,16 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
/// <summary>
/// PR E.2 — pins the .NET SDK surface for the new alarm RPCs:
/// Pins the .NET SDK surface for the alarm RPCs:
/// <see cref="MxGatewayClient.AcknowledgeAlarmAsync"/> and
/// <see cref="MxGatewayClient.QueryActiveAlarmsAsync"/>.
/// <see cref="MxGatewayClient.StreamAlarmsAsync"/>.
/// </summary>
public sealed class MxGatewayClientAlarmsTests
{
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
[Fact]
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
{
@@ -47,7 +46,6 @@ public sealed class MxGatewayClientAlarmsTests
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
}
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
[Fact]
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
{
@@ -71,21 +69,18 @@ public sealed class MxGatewayClientAlarmsTests
cancellation.Token));
}
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
[Fact]
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
public async Task AcknowledgeAlarmAsync_SurfacesRpcExceptionFromFakeTransportVerbatim_WhenMappingDisabled()
{
// 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();
transport.AcknowledgeAlarmExceptions.Enqueue(
new RpcException(new Status(StatusCode.Unauthenticated, "expired key")));
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>(
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
{
@@ -96,54 +91,73 @@ public sealed class MxGatewayClientAlarmsTests
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
}
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
[Fact]
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
{
// 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();
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked));
await using MxGatewayClient client = CreateClient(transport);
List<ActiveAlarmSnapshot> snapshots = [];
await foreach (ActiveAlarmSnapshot snapshot in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest
List<AlarmFeedMessage> messages = [];
await foreach (AlarmFeedMessage message in client.StreamAlarmsAsync(new StreamAlarmsRequest()))
{
SessionId = "session-fixture",
}))
{
snapshots.Add(snapshot);
messages.Add(message);
}
Assert.Equal(2, snapshots.Count);
Assert.Equal("Tank01.Level.HiHi", snapshots[0].AlarmFullReference);
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState);
Assert.Single(transport.QueryActiveAlarmsCalls);
Assert.Equal(3, messages.Count);
Assert.Equal("Tank01.Level.HiHi", messages[0].ActiveAlarm.AlarmFullReference);
Assert.Equal(AlarmConditionState.Active, messages[0].ActiveAlarm.CurrentState);
Assert.Equal(AlarmConditionState.ActiveAcked, messages[1].ActiveAlarm.CurrentState);
Assert.True(messages[2].SnapshotComplete);
Assert.Single(transport.StreamAlarmsCalls);
}
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
[Fact]
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
public async Task StreamAlarmsAsync_PassesFilterPrefix()
{
FakeGatewayTransport transport = CreateTransport();
await using MxGatewayClient client = CreateClient(transport);
await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(new StreamAlarmsRequest
{
SessionId = "session-fixture",
AlarmFilterPrefix = "Tank01.",
}))
{
// no snapshots enqueued; just verifying the request passes through
// only the snapshot-complete sentinel; verifying the request passes through
}
var call = Assert.Single(transport.QueryActiveAlarmsCalls);
var call = Assert.Single(transport.StreamAlarmsCalls);
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
}
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
[Fact]
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
public async Task StreamAlarmsAsync_HonorsCancellationDuringEnumeration()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
@@ -153,8 +167,8 @@ public sealed class MxGatewayClientAlarmsTests
using CancellationTokenSource cancellation = new();
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(
new QueryActiveAlarmsRequest { SessionId = "session-fixture" },
await foreach (AlarmFeedMessage _ in client.StreamAlarmsAsync(
new StreamAlarmsRequest(),
cancellation.Token))
{
cancellation.Cancel();
@@ -1,9 +1,9 @@
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Client.Cli;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Client.Cli;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
/// <summary>Tests for the CLI command interface.</summary>
public sealed class MxGatewayClientCliTests
@@ -106,6 +106,43 @@ public sealed class MxGatewayClientCliTests
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>
[Fact]
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
@@ -147,6 +184,69 @@ public sealed class MxGatewayClientCliTests
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>
[Fact]
@@ -449,414 +549,139 @@ public sealed class MxGatewayClientCliTests
Assert.Contains("\"objectCount\": 99", text);
}
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
/// <summary>Verifies that batch mode executes a single no-gateway command and writes the EOR sentinel.</summary>
[Fact]
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
public async Task RunAsync_Batch_SingleVersionCommand_WritesOutputAndEorSentinel()
{
using var output = new StringWriter();
using var error = new StringWriter();
using var input = new StringReader("version --json\n");
using var stdin = new StringReader("version --json\n");
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: null,
standardInput: input);
standardInput: stdin);
Assert.Equal(0, exitCode);
string text = output.ToString();
Assert.Contains("\"gatewayProtocolVersion\":3", text);
Assert.Contains("\"gatewayProtocolVersion\"", text);
Assert.Contains("__MXGW_BATCH_EOR__", text);
// The EOR marker must come after the JSON output.
int jsonIndex = text.IndexOf("\"gatewayProtocolVersion\"", StringComparison.Ordinal);
int eorIndex = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
Assert.True(jsonIndex >= 0 && eorIndex > jsonIndex);
// Sentinel must appear after the output, not before.
int outputIdx = text.IndexOf("gatewayProtocolVersion", StringComparison.Ordinal);
int eorIdx = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
Assert.True(outputIdx < eorIdx, "EOR sentinel must follow command output.");
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
/// <summary>Verifies that batch mode processes two commands sequentially and writes two EOR sentinels.</summary>
[Fact]
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
public async Task RunAsync_Batch_TwoVersionCommands_WritesTwoEorSentinels()
{
using var output = new StringWriter();
using var error = new StringWriter();
// Unknown command should produce an error on the captured error stream,
// which batch mode re-emits to stdout inside the same delimited block.
using var input = new StringReader("nope-not-a-command\nversion\n");
// Two commands followed by EOF (end of string).
using var stdin = new StringReader("version\nversion --json\n");
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: null,
standardInput: input);
standardInput: stdin);
Assert.Equal(0, exitCode);
string text = output.ToString();
// Two records → two EOR markers.
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
int secondEor = text.IndexOf(
"__MXGW_BATCH_EOR__",
firstEor + 1,
StringComparison.Ordinal);
Assert.True(firstEor > 0);
Assert.True(secondEor > firstEor);
// The unknown-command error message must be on stdout (not on stderr).
Assert.Contains("nope-not-a-command", text);
Assert.DoesNotContain("nope-not-a-command", error.ToString());
// The follow-up `version` line should still succeed.
Assert.Contains("gateway-protocol=", text);
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
Assert.True(firstEor >= 0, "First EOR sentinel must be present.");
Assert.True(secondEor > firstEor, "Second EOR sentinel must follow first.");
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>
/// Client.Dotnet-018: the README CLI examples for the alarm subcommands at
/// `clients/dotnet/README.md` must drive cleanly through the production
/// CLI argument parser. The previous text used non-existent flags
/// (`--session-id`, `--max-messages`, `--alarm-reference`) that would
/// fail with "Unknown command" / "Missing required option --reference".
/// Each documented example is extracted from the README, parsed via the
/// production <see cref="MxGatewayClientCli.RunAsync"/>, and asserted
/// against exit code 0.
/// </summary>
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
[Theory]
[InlineData("stream-alarms")]
[InlineData("acknowledge-alarm")]
public async Task RunAsync_ReadmeExamples_ForAlarmCommands_ParseSuccessfully(string command)
{
string readme = LocateClientReadme();
string[] commandLine = ExtractReadmeCommandLine(readme, command);
// The documented examples do not include --api-key (the README assumes
// the env var path documented elsewhere). Inject an API key via the
// standard env var so CreateOptions succeeds and the parser fully
// exercises the documented flag shape.
string? previousKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", "test-api-key");
try
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
{
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "fixture" },
});
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
{
CorrelationId = "ack-fixture",
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
int exitCode = await MxGatewayClientCli.RunAsync(
commandLine,
output,
error,
_ => fakeClient);
Assert.True(
exitCode == 0,
$"README example for '{command}' exited {exitCode}; stderr=<<{error}>>");
Assert.DoesNotContain("Unknown command", error.ToString());
Assert.DoesNotContain("Missing required option", error.ToString());
}
finally
{
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", previousKey);
}
}
/// <summary>
/// Client.Dotnet-019: `BenchReadBulkAsync` previously fell back to
/// <c>reply.ReturnValue.Int32Value</c> when the register reply had no
/// typed <c>Register</c> payload, silently driving the rest of the bench
/// against a zero server handle. The fix must fail loudly with a
/// descriptive <see cref="MxGatewayException"/>.
/// </summary>
/// <summary>Verifies that batch mode on EOF (empty stdin) exits 0 immediately without writing any sentinel.</summary>
[Fact]
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
public async Task RunAsync_Batch_EmptyStdin_ExitsZeroWithNoOutput()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
// Successful protocol + MX status but no typed `Register` payload.
// Before the Client.Dotnet-019 fix this silently became serverHandle=0
// and the bench proceeded through SubscribeBulk / warmup / steady-state
// against an invalid handle, producing a misleading zero-result summary.
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Register,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
using var stdin = new StringReader(string.Empty);
int exitCode = await MxGatewayClientCli.RunAsync(
[
"bench-read-bulk",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--duration-seconds",
"1",
"--warmup-seconds",
"0",
"--bulk-size",
"1",
],
["batch"],
output,
error,
_ => fakeClient);
clientFactory: null,
standardInput: stdin);
Assert.Equal(1, exitCode);
// Descriptive message that names the missing typed payload.
string err = error.ToString();
Assert.Contains("Register", err);
// The bench must not produce any aggregate stats JSON.
Assert.DoesNotContain("bench-read-bulk", output.ToString());
Assert.Equal(0, exitCode);
Assert.Equal(string.Empty, output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>
/// Client.Dotnet-020: the steady-state loop in `BenchReadBulkAsync` had a
/// bare `catch { failedCalls++; continue; }` that swallowed
/// <see cref="OperationCanceledException"/>, so token-driven cancellation
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
/// the bench must exit promptly when the supplied token cancels.
/// 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_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
public async Task RunAsync_Batch_CommandFailure_WritesErrorJsonToStdoutAndContinues()
{
using var output = new StringWriter();
using var error = new StringWriter();
int invokeCount = 0;
FakeCliClient fakeClient = new()
{
InvokeHandler = (request, ct) =>
{
int n = Interlocked.Increment(ref invokeCount);
// Reply 1 = Register (success with typed payload).
if (request.Command.Kind == MxCommandKind.Register)
{
return Task.FromResult(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Register,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Register = new RegisterReply { ServerHandle = 1 },
});
}
// Reply 2 = SubscribeBulk (success).
if (request.Command.Kind == MxCommandKind.SubscribeBulk)
{
var subscribeReply = new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.SubscribeBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
SubscribeBulk = new BulkSubscribeReply(),
};
return Task.FromResult(subscribeReply);
}
// ReadBulk reply 1 = success (so the steady-state loop enters
// and starts iterating). Reply 2+ = simulated cancellation.
if (request.Command.Kind == MxCommandKind.ReadBulk && n <= 3)
{
return Task.FromResult(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.ReadBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
ReadBulk = new BulkReadReply(),
});
}
// From here on every ReadBulk throws OCE — the steady-state
// loop must exit promptly rather than spinning until
// --duration-seconds elapses.
throw new OperationCanceledException();
},
};
var sw = System.Diagnostics.Stopwatch.StartNew();
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
await MxGatewayClientCli.RunAsync(
[
"bench-read-bulk",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--duration-seconds",
"30",
"--warmup-seconds",
"0",
"--bulk-size",
"1",
],
output,
error,
_ => fakeClient));
sw.Stop();
// Without the fix the loop swallows OCE and continues until the 30 s
// steady-state deadline expires. With the fix it exits as soon as OCE
// surfaces. Generous 10 s ceiling to keep the test stable under load.
Assert.True(
sw.Elapsed < TimeSpan.FromSeconds(10),
$"Bench did not exit promptly on cancellation; took {sw.Elapsed}.");
}
/// <summary>
/// Client.Dotnet-021: both `ReadBulkAsync` and `BenchReadBulkAsync` cast
/// the user-supplied <c>--timeout-ms</c> to <see cref="uint"/> without
/// bounds checking, so a negative value (e.g. <c>-1</c>) silently wraps
/// to ~49.7 days. The fix must reject negatives with a clear error.
/// </summary>
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
[Theory]
[InlineData("read-bulk")]
[InlineData("bench-read-bulk")]
public async Task RunAsync_TimeoutMs_NegativeValue_RejectsWithClearError(string command)
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
string[] args = command is "read-bulk"
? [
"read-bulk",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--session-id",
"session-fixture",
"--server-handle",
"1",
"--items",
"Area001.Pump001.Speed",
"--timeout-ms",
"-1",
]
: [
"bench-read-bulk",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--duration-seconds",
"1",
"--warmup-seconds",
"0",
"--bulk-size",
"1",
"--timeout-ms",
"-1",
];
// 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(
args,
["batch"],
output,
error,
_ => fakeClient);
clientFactory: _ => throw new InvalidOperationException("injected failure"),
standardInput: stdin);
Assert.NotEqual(0, exitCode);
string err = error.ToString();
Assert.Contains("timeout-ms", err);
Assert.Contains("non-negative", err);
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>
/// Locates the .NET client README by walking up from the test assembly's
/// base directory until <c>clients/dotnet/README.md</c> is found. Keeps
/// the regression test independent of the current working directory.
/// </summary>
private static string LocateClientReadme()
/// <summary>Verifies that batch mode treats an empty (blank) line as EOF and exits 0.</summary>
[Fact]
public async Task RunAsync_Batch_EmptyLine_ExitsZeroAfterPreviousCommands()
{
string? directory = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(directory))
{
string candidate = Path.Combine(directory, "clients", "dotnet", "README.md");
if (File.Exists(candidate))
{
return candidate;
}
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");
directory = Path.GetDirectoryName(directory);
}
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: null,
standardInput: stdin);
throw new FileNotFoundException("clients/dotnet/README.md not found above test assembly base directory.");
}
/// <summary>
/// Extracts the documented CLI invocation for the requested subcommand
/// from the README, returning only the arguments after the
/// <c>mxgw-dotnet</c>-equivalent prefix so they can be passed straight
/// to <see cref="MxGatewayClientCli.RunAsync"/>.
/// </summary>
private static string[] ExtractReadmeCommandLine(string readmePath, string command)
{
string[] lines = File.ReadAllLines(readmePath);
// Look for the documented `dotnet run ... -- <command> ...` line.
foreach (string line in lines)
{
int dashes = line.IndexOf("-- " + command, StringComparison.Ordinal);
if (dashes < 0)
{
continue;
}
string after = line[(dashes + 3)..].Trim();
// Tokenize by whitespace, respecting "..." quoted segments.
return TokenizeCommandLine(after);
}
throw new InvalidOperationException(
$"README at '{readmePath}' has no documented example for subcommand '{command}'.");
}
/// <summary>
/// Splits a single command-line string into argv tokens, honouring
/// double-quoted segments so paths with embedded spaces survive intact.
/// </summary>
private static string[] TokenizeCommandLine(string input)
{
var tokens = new List<string>();
var current = new System.Text.StringBuilder();
bool inQuotes = false;
foreach (char ch in input)
{
if (ch == '"')
{
inQuotes = !inQuotes;
continue;
}
if (!inQuotes && char.IsWhiteSpace(ch))
{
if (current.Length > 0)
{
tokens.Add(current.ToString());
current.Clear();
}
continue;
}
current.Append(ch);
}
if (current.Length > 0)
{
tokens.Add(current.ToString());
}
return tokens.ToArray();
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>
@@ -877,8 +702,13 @@ public sealed class MxGatewayClientCliTests
/// <summary>Exception to throw on invoke, if any.</summary>
public Exception? InvokeFailure { get; init; }
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { 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 />
public ValueTask DisposeAsync()
@@ -925,11 +755,6 @@ public sealed class MxGatewayClientCliTests
throw InvokeFailure;
}
if (InvokeHandler is not null)
{
return InvokeHandler(request, cancellationToken);
}
return Task.FromResult(InvokeReplies.Dequeue());
}
@@ -944,6 +769,11 @@ public sealed class MxGatewayClientCliTests
await Task.Yield();
yield return gatewayEvent;
}
if (StreamHangAfterEvents is not null)
{
await StreamHangAfterEvents(cancellationToken).ConfigureAwait(false);
}
}
/// <summary>Queue of acknowledge-alarm replies to return.</summary>
@@ -990,7 +820,6 @@ public sealed class MxGatewayClientCliTests
/// <summary>Galaxy discover hierarchy reply to return.</summary>
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
/// <summary>Queue of galaxy discover hierarchy replies to return.</summary>
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
/// <summary>List of received galaxy test connection requests.</summary>
@@ -1,6 +1,6 @@
using ZB.MOM.WW.MxGateway.Contracts;
using MxGateway.Contracts;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
public sealed class MxGatewayClientContractInfoTests
{
@@ -1,4 +1,4 @@
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
public sealed class MxGatewayClientOptionsTests
{
@@ -1,7 +1,7 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
using Grpc.Core;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
/// <summary>Tests for MxGatewaySession and client command behavior.</summary>
public sealed class MxGatewayClientSessionTests
@@ -184,6 +184,96 @@ public sealed class MxGatewayClientSessionTests
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>
[Fact]
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
@@ -231,6 +321,52 @@ public sealed class MxGatewayClientSessionTests
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>
[Fact]
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
@@ -255,6 +391,35 @@ public sealed class MxGatewayClientSessionTests
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>
[Fact]
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
@@ -303,6 +468,84 @@ public sealed class MxGatewayClientSessionTests
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)
{
return new MxGatewayClient(transport.Options, transport);
@@ -1,4 +1,4 @@
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
public sealed class MxGatewayGeneratedContractTests
{
@@ -1,9 +1,9 @@
using System.Text.Json;
using Google.Protobuf;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
public sealed class MxStatusProxyExtensionsTests
{
@@ -1,9 +1,9 @@
using System.Text.Json;
using Google.Protobuf;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
namespace MxGateway.Client.Tests;
public sealed class MxValueExtensionsTests
{
@@ -0,0 +1,76 @@
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
@@ -0,0 +1,76 @@
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
@@ -0,0 +1,67 @@
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; }
}
@@ -2,14 +2,14 @@ using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Contracts.Proto.Galaxy;
using Polly;
using System.Net.Http;
using System.Net.Security;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
@@ -19,12 +19,11 @@ namespace ZB.MOM.WW.MxGateway.Client;
public sealed class GalaxyRepositoryClient : IAsyncDisposable
{
private const int DiscoverHierarchyPageSize = 5000;
private const int BrowseChildrenPageSize = 500;
private readonly GrpcChannel? _channel;
private readonly IGalaxyRepositoryClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private bool _disposed;
private int _disposed;
/// <summary>
/// Initializes a Galaxy Repository client with custom transport and options.
@@ -183,10 +182,17 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
}
/// <summary>Discovers the Galaxy object hierarchy.</summary>
/// <param name="options">Client configuration options.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The collection of Galaxy objects in the hierarchy.</returns>
/// <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(
DiscoverHierarchyOptions options,
CancellationToken cancellationToken = default)
@@ -279,89 +285,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
cancellationToken);
}
/// <summary>Returns root-level browse nodes (objects with no parent).</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
public Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(CancellationToken cancellationToken = default)
=> BrowseAsync(null, cancellationToken);
/// <summary>Returns root-level browse nodes filtered by the given options.</summary>
/// <param name="options">Browse filter options. Null applies no filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
public async Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(
BrowseChildrenOptions? options,
CancellationToken cancellationToken = default)
{
BrowseChildrenOptions effective = options ?? new BrowseChildrenOptions();
List<LazyBrowseNode> roots = [];
string pageToken = string.Empty;
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do
{
BrowseChildrenRequest request = BuildBrowseChildrenRequest(effective);
request.PageToken = pageToken;
BrowseChildrenReply reply = await BrowseChildrenRawAsync(request, cancellationToken).ConfigureAwait(false);
for (int i = 0; i < reply.Children.Count; i++)
{
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
roots.Add(new LazyBrowseNode(this, reply.Children[i], hint, effective));
}
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
return roots;
}
/// <summary>Issues a raw BrowseChildren RPC without result wrapping.</summary>
/// <param name="request">The browse-children request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<BrowseChildrenReply> BrowseChildrenRawAsync(
BrowseChildrenRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.BrowseChildrenAsync(request, CreateCallOptions(token)),
cancellationToken);
}
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
{
ArgumentNullException.ThrowIfNull(options);
BrowseChildrenRequest request = new()
{
PageSize = BrowseChildrenPageSize,
AlarmBearingOnly = options.AlarmBearingOnly,
HistorizedOnly = options.HistorizedOnly,
};
request.CategoryIds.Add(options.CategoryIds);
request.TemplateChainContains.Add(options.TemplateChainContains);
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
{
request.TagNameGlob = options.TagNameGlob;
}
if (options.IncludeAttributes.HasValue)
{
request.IncludeAttributes = options.IncludeAttributes.Value;
}
return request;
}
/// <summary>
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
/// current state on subscribe so callers can prime their cache, then emits one event
@@ -426,12 +349,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
/// </summary>
public ValueTask DisposeAsync()
{
if (_disposed)
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return ValueTask.CompletedTask;
}
_disposed = true;
_channel?.Dispose();
return ValueTask.CompletedTask;
}
@@ -490,10 +412,7 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
.ConfigureAwait(false);
}
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options);
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
{
SocketsHttpHandler handler = new()
{
@@ -513,11 +432,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
{
return false;
}
if (certificate is null)
{
return false;
@@ -533,10 +447,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
return customChain.Build(certificateToValidate);
};
}
else if (!options.RequireCertificateValidation)
{
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
}
}
return handler;
@@ -544,6 +454,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
}
}
@@ -1,7 +1,7 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>
/// gRPC implementation of IGalaxyRepositoryClientTransport.
@@ -36,7 +36,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
}
}
@@ -53,7 +53,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
}
}
@@ -70,24 +70,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
/// <inheritdoc />
public async Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.BrowseChildrenAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
}
}
@@ -118,7 +101,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
}
yield return deployEvent;
@@ -132,28 +115,4 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
{
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 ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>
/// gRPC implementation of IMxGatewayClientTransport.
@@ -36,7 +36,7 @@ internal sealed class GrpcMxGatewayClientTransport(
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
}
}
@@ -53,7 +53,7 @@ internal sealed class GrpcMxGatewayClientTransport(
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
}
}
@@ -70,7 +70,7 @@ internal sealed class GrpcMxGatewayClientTransport(
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
}
}
@@ -101,7 +101,7 @@ internal sealed class GrpcMxGatewayClientTransport(
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
}
yield return gatewayEvent;
@@ -129,52 +129,10 @@ internal sealed class GrpcMxGatewayClientTransport(
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
throw RpcExceptionMapper.Map(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 />
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
@@ -202,7 +160,7 @@ internal sealed class GrpcMxGatewayClientTransport(
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
}
yield return message;
@@ -216,28 +174,4 @@ internal sealed class GrpcMxGatewayClientTransport(
{
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 ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>Transport layer for Galaxy Repository gRPC operations.</summary>
internal interface IGalaxyRepositoryClientTransport
@@ -33,13 +33,6 @@ internal interface IGalaxyRepositoryClientTransport
DiscoverHierarchyRequest request,
CallOptions callOptions);
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
/// <param name="request">The browse children request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions);
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
/// <param name="request">The watch deploy events request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
@@ -1,7 +1,7 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
internal interface IMxGatewayClientTransport
{
@@ -65,17 +65,6 @@ internal interface IMxGatewayClientTransport
AcknowledgeAlarmRequest request,
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>
/// Attaches to the gateway's central alarm feed — the current active-alarm
/// snapshot followed by live transitions.
@@ -1,6 +1,6 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>Exception thrown when an MXAccess command fails with a non-zero HResult or failing status.</summary>
public sealed class MxAccessException : MxGatewayCommandException
@@ -1,6 +1,6 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>Extension methods for checking MxCommandReply success conditions.</summary>
public static class MxCommandReplyExtensions
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Polly.Core" Version="8.6.6" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -1,6 +1,7 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Grpc.Core;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>Exception thrown when an API key is invalid, expired, or malformed.</summary>
public sealed class MxGatewayAuthenticationException : MxGatewayException
@@ -13,6 +14,7 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</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(
string message,
string? sessionId = null,
@@ -20,7 +22,8 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
Exception? innerException = null,
StatusCode? statusCode = null)
: base(
message,
sessionId,
@@ -28,7 +31,8 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
protocolStatus,
hResult,
statuses ?? [],
innerException)
innerException,
statusCode)
{
}
}
@@ -1,6 +1,7 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Grpc.Core;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>Exception thrown when the API key lacks required scopes for an operation.</summary>
public sealed class MxGatewayAuthorizationException : MxGatewayException
@@ -13,6 +14,7 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</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(
string message,
string? sessionId = null,
@@ -20,7 +22,8 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
Exception? innerException = null,
StatusCode? statusCode = null)
: base(
message,
sessionId,
@@ -28,7 +31,8 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
protocolStatus,
hResult,
statuses ?? [],
innerException)
innerException,
statusCode)
{
}
}
@@ -1,13 +1,13 @@
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
using Polly;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>
/// 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 IMxGatewayClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private bool _disposed;
private int _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="MxGatewayClient"/> with given options and transport.
@@ -184,9 +184,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
/// <summary>
/// Acknowledges an active MXAccess alarm condition through the gateway. The
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
/// scope and forwards the acknowledge to the worker's MXAccess session;
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
/// gateway authorizes <see cref="AcknowledgeAlarmRequest"/> against the API
/// key's <c>admin</c> scope (there is no finer-grained alarm-ack sub-scope)
/// and forwards the acknowledge to the worker's MXAccess session; the
/// resulting <see cref="MxStatusProxy"/> is returned in the reply.
/// </summary>
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
@@ -203,27 +204,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
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>
/// Attaches to the gateway's central alarm feed. The stream opens with one
/// <see cref="AlarmFeedMessage"/> per currently-active alarm (the
@@ -251,12 +231,11 @@ public sealed class MxGatewayClient : IAsyncDisposable
/// </summary>
public ValueTask DisposeAsync()
{
if (_disposed)
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return ValueTask.CompletedTask;
}
_disposed = true;
_channel?.Dispose();
return ValueTask.CompletedTask;
}
@@ -315,10 +294,7 @@ public sealed class MxGatewayClient : IAsyncDisposable
.ConfigureAwait(false);
}
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options);
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
{
SocketsHttpHandler handler = new()
{
@@ -338,11 +314,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
{
return false;
}
if (certificate is null)
{
return false;
@@ -358,10 +329,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
return customChain.Build(certificateToValidate);
};
}
else if (!options.RequireCertificateValidation)
{
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
}
}
return handler;
@@ -369,6 +336,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
}
}
@@ -0,0 +1,25 @@
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,6 +1,6 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>
/// Configures the gRPC channel used by the .NET MXAccess Gateway client.
@@ -27,14 +27,6 @@ public sealed class MxGatewayClientOptions
/// </summary>
public string? CaCertificatePath { get; init; }
/// <summary>
/// When true, TLS connections without a pinned <see cref="CaCertificatePath"/>
/// use the OS trust store. When false (default), the gateway certificate is
/// accepted without verification — appropriate for this internal tool's
/// auto-generated self-signed certificate. Pinning a CA always verifies.
/// </summary>
public bool RequireCertificateValidation { get; init; }
/// <summary>
/// Gets the server name override for SNI during TLS handshake.
/// </summary>
@@ -46,7 +38,12 @@ public sealed class MxGatewayClientOptions
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Gets the default timeout for unary gRPC calls.
/// Gets the timeout budget for a unary gRPC operation. This is both the gRPC
/// 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>
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
@@ -56,7 +53,9 @@ public sealed class MxGatewayClientOptions
public TimeSpan? StreamTimeout { get; init; }
/// <summary>
/// Gets the maximum size in bytes for gRPC messages.
/// 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;
@@ -1,4 +1,4 @@
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>Configuration for automatic retry behavior on transient gRPC call failures.</summary>
public sealed class MxGatewayClientRetryOptions
@@ -1,10 +1,10 @@
using Grpc.Core;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
using Polly;
using Polly.Retry;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>Factory and helpers for exponential-backoff retry policies on transient gRPC failures.</summary>
internal static class MxGatewayClientRetryPolicy
@@ -61,8 +61,13 @@ internal static class MxGatewayClientRetryPolicy
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
or StatusCode.DeadlineExceeded
or StatusCode.ResourceExhausted;
}
}
@@ -1,6 +1,6 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>Exception thrown when a gateway command fails due to an unclassified protocol error.</summary>
public class MxGatewayCommandException : MxGatewayException
@@ -1,6 +1,7 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Grpc.Core;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>
/// Exception thrown when a gateway RPC call fails or returns an error status.
@@ -28,6 +29,20 @@ public class MxGatewayException : Exception
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>
/// Initializes a new instance of the MxGatewayException class with full diagnostic information.
/// </summary>
@@ -38,6 +53,7 @@ public class MxGatewayException : Exception
/// <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="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(
string message,
string? sessionId,
@@ -45,7 +61,8 @@ public class MxGatewayException : Exception
ProtocolStatus? protocolStatus,
int? hResult,
IReadOnlyList<MxStatusProxy> statuses,
Exception? innerException = null)
Exception? innerException = null,
StatusCode? statusCode = null)
: base(message, innerException)
{
SessionId = sessionId;
@@ -53,6 +70,7 @@ public class MxGatewayException : Exception
ProtocolStatus = protocolStatus;
HResultCode = hResult;
Statuses = statuses;
StatusCode = statusCode;
}
/// <summary>
@@ -79,4 +97,15 @@ public class MxGatewayException : Exception
/// Gets the list of MXAccess status codes returned by the operation.
/// </summary>
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 ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>
/// Represents one gateway-backed MXAccess session.
@@ -9,7 +9,10 @@ public sealed class MxGatewaySession : IAsyncDisposable
{
private readonly MxGatewayClient _client;
private readonly SemaphoreSlim _closeLock = new(1, 1);
private readonly object _disposeGate = new();
private CloseSessionReply? _closeReply;
private int _activeCloseCount;
private bool _closeLockDisposed;
/// <summary>
/// Initializes a new session backed by the given MXAccess gateway client.
@@ -46,23 +49,42 @@ public sealed class MxGatewaySession : IAsyncDisposable
return _closeReply;
}
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
// 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
{
if (_closeReply is not null)
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_closeReply is not null)
{
return _closeReply;
}
_closeReply = await _client.CloseSessionRawAsync(
new CloseSessionRequest { SessionId = SessionId },
cancellationToken)
.ConfigureAwait(false);
return _closeReply;
}
_closeReply = await _client.CloseSessionRawAsync(
new CloseSessionRequest { SessionId = SessionId },
cancellationToken)
.ConfigureAwait(false);
return _closeReply;
finally
{
_closeLock.Release();
}
}
finally
{
_closeLock.Release();
lock (_disposeGate)
{
_activeCloseCount--;
}
}
}
@@ -79,7 +101,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value;
return reply.Register?.ServerHandle
?? throw CreateMissingPayloadException(reply, "register");
}
/// <summary>
@@ -121,7 +144,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
return reply.AddItem?.ItemHandle
?? throw CreateMissingPayloadException(reply, "add_item");
}
/// <summary>
@@ -172,7 +196,8 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value;
return reply.AddItem2?.ItemHandle
?? throw CreateMissingPayloadException(reply, "add_item2");
}
/// <summary>
@@ -504,14 +529,10 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <summary>
/// Bulk Write — sequential MXAccess Write per entry on the worker's STA.
/// Per-item failures appear as <see cref="BulkWriteResult"/> entries with
/// Per-item failures appear as BulkWriteResult entries with
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
/// Protocol-level failures still throw via EnsureProtocolSuccess.
/// </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(
int serverHandle,
IReadOnlyList<WriteBulkEntry> entries,
@@ -534,15 +555,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.WriteBulk?.Results.ToArray() ?? [];
}
/// <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>
/// <summary>Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.</summary>
public async Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
int serverHandle,
IReadOnlyList<Write2BulkEntry> entries,
@@ -570,10 +583,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// Credential-sensitive values must never reach logs; the client mirrors
/// the single-item WriteSecured redaction contract.
/// </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(
int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries,
@@ -596,14 +605,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.WriteSecuredBulk?.Results.ToArray() ?? [];
}
/// <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>
/// <summary>Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per entry.</summary>
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries,
@@ -629,17 +631,11 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <summary>
/// Bulk Read — snapshot the current value for each requested tag.
/// Returns the cached OnDataChange value when the tag is already advised
/// (<c>WasCached = true</c>), otherwise the worker takes the full AddItem +
/// (was_cached = true), otherwise the worker takes the full AddItem +
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle. Per-tag
/// failures (timeout, invalid tag) appear as <see cref="BulkReadResult"/>
/// entries with <c>WasSuccessful = false</c>; the call never throws on
/// per-tag errors.
/// failures (timeout, invalid tag) appear as BulkReadResult entries with
/// <c>WasSuccessful = false</c>; the call never throws on per-tag errors.
/// </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(
int serverHandle,
IReadOnlyList<string> tagAddresses,
@@ -823,7 +819,32 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// </summary>
public async ValueTask DisposeAsync()
{
lock (_disposeGate)
{
if (_closeLockDisposed)
{
return;
}
}
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();
}
@@ -841,4 +862,21 @@ public sealed class MxGatewaySession : IAsyncDisposable
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 ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>Exception thrown when a session is not found, not ready, or invalid.</summary>
public sealed class MxGatewaySessionException : MxGatewayException
@@ -1,6 +1,6 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>Exception thrown when the worker process is unavailable or fails to process a command.</summary>
public sealed class MxGatewayWorkerException : MxGatewayException
@@ -1,6 +1,6 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>Extension methods for MxStatusProxy values.</summary>
public static class MxStatusProxyExtensions
@@ -1,8 +1,8 @@
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
namespace MxGateway.Client;
/// <summary>
/// Creates and projects gateway MXAccess values without hiding the raw
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MxGateway.Client.Tests")]
@@ -0,0 +1,55 @@
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),
};
}
}
+81 -120
View File
@@ -7,11 +7,11 @@ CLI, and unit tests.
| Project | Purpose |
|---------|---------|
| `ZB.MOM.WW.MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
| `ZB.MOM.WW.MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
| `ZB.MOM.WW.MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
| `MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
| `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. |
The projects reference `src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` so
The projects reference `src/MxGateway.Contracts/MxGateway.Contracts.csproj` so
the client compiles against the same generated protobuf and gRPC types as the
gateway. `clients/dotnet/generated` remains reserved for generator output if a
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
```powershell
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx --no-build
dotnet build clients/dotnet/MxGateway.Client.sln
dotnet test clients/dotnet/MxGateway.Client.sln --no-build
```
## Packaging
@@ -29,8 +29,8 @@ Create local library and CLI artifacts from the repository root:
```powershell
$dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet'
dotnet pack clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput"
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
dotnet pack clients/dotnet/MxGateway.Client/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
```
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
The .NET client uses the generated C# types from
`src/ZB.MOM.WW.MxGateway.Contracts/Generated`. Regenerate those files through the
`src/MxGateway.Contracts/Generated`. Regenerate those files through the
contracts project:
```powershell
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj
```
## Client Usage
@@ -84,14 +84,47 @@ messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
available, and command helpers have `*RawAsync` variants when callers need the
complete `MxCommandReply`.
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
the active alarms the gateway's central monitor currently holds),
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
reference, optional comment, ack target). All three accept a cancellation
token and pass through the `MxGateway:Alarms` configuration on the
server — when alarms are disabled, the gateway returns an empty list / empty
stream rather than failing.
### Bulk Commands
The session exposes bulk variants for every command family that has one
upstream — they all carry a list of entries in one gRPC round-trip, the worker
runs the per-item MXAccess calls sequentially on its STA, and the reply
returns one result per requested entry. Per-entry failures populate
`WasSuccessful = false` with the underlying HRESULT and never throw; only
protocol-level failures throw via `EnsureProtocolSuccess`.
```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
the first `CloseSessionReply` instead of sending another close request.
@@ -121,28 +154,38 @@ can keep the full `MxCommandReply`, HRESULT, and status array when MXAccess
itself rejects a command. `MxAccessException.Reply` contains the raw generated
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
The test CLI supports deterministic JSON output for automation:
```powershell
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- version --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/ZB.MOM.WW.MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --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/ZB.MOM.WW.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 -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
dotnet run --project clients/dotnet/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/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/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/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/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,
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
output redacts API keys supplied through `--api-key`.
output redacts the effective API key, whether it was supplied through
`--api-key` or resolved from the `--api-key-env` environment variable.
## Galaxy Repository Browse
@@ -191,59 +234,11 @@ IReadOnlyList<GalaxyObject> pumps = await repository.DiscoverHierarchyAsync(
The CLI exposes the same operations:
```powershell
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/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/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
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/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
```
### Browsing lazily
For UI trees or OPC UA bridges, use `BrowseChildrenAsync` to walk one level at a
time instead of paging the full hierarchy. Pass an empty request for root objects;
subsequent calls supply `ParentGobjectId`, `ParentTagName`, or
`ParentContainedPath`. Each child's `ChildHasChildren[i]` tells you whether to
draw an expand triangle. Filter fields match `DiscoverHierarchy`. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```csharp
BrowseChildrenReply roots = await repository.BrowseChildrenAsync(
new BrowseChildrenRequest());
for (int i = 0; i < roots.Children.Count; i++)
{
GalaxyObject child = roots.Children[i];
bool hasChildren = roots.ChildHasChildren[i];
Console.WriteLine($"{child.TagName} expand={hasChildren}");
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = apiKey });
IReadOnlyList<LazyBrowseNode> roots = await repository.BrowseAsync();
foreach (LazyBrowseNode root in roots)
{
if (root.HasChildrenHint)
{
await root.ExpandAsync();
}
foreach (LazyBrowseNode child in root.Children)
{
Console.WriteLine($"{child.Object.TagName} ({(child.HasChildrenHint ? "has children" : "leaf")})");
}
}
```
`ExpandAsync` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`BrowseAsync` again from the root.
### Watching deploy events
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
@@ -276,28 +271,17 @@ await foreach (DeployEvent evt in repository.WatchDeployEventsAsync(
The CLI counterpart streams events until Ctrl+C (or `--max-events`):
```powershell
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/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/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
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/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
```
Use TLS options for a secured gateway:
```powershell
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
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
```
### TLS trust
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
the client is **lenient by default**: a TLS connection (`UseTls` / `--tls`) with
no pinned CA accepts whatever certificate the gateway presents. To verify
instead, pin a CA with `CaCertificatePath` / `--ca-file` (this path also enforces
the certificate hostname/SAN match), or set `RequireCertificateValidation` to
force OS/system-trust verification without pinning. Use `ServerNameOverride` /
`--server-name` when the dialed host differs from the certificate SAN. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
@@ -307,32 +291,9 @@ $env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'http://localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
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
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
```
## Installing as a NuGet Package
The client publishes to the internal Gitea NuGet feed at
`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`.
Add the feed once:
````bash
dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json \
--name dohertj2-gitea \
--username <gitea-username> \
--password <gitea-token-or-password> \
--store-password-in-clear-text
````
Then add the package to your project:
````bash
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.1.0
````
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
@@ -1,34 +0,0 @@
using Grpc.Core;
using Grpc.Net.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Live smoke tests for the BrowseChildren RPC. Skipped by default; set
/// MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to run against a real gateway.
/// </summary>
public sealed class BrowseChildrenSmokeTests
{
/// <summary>
/// Verifies that BrowseChildren returns a non-zero cache sequence and
/// a consistent children/child-has-children count from a live gateway.
/// </summary>
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
{
string? apiKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
string endpoint = Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") ?? "http://localhost:5120";
Assert.False(string.IsNullOrEmpty(apiKey), "MXGATEWAY_API_KEY must be set.");
using GrpcChannel channel = GrpcChannel.ForAddress(endpoint);
GalaxyRepository.GalaxyRepositoryClient client = new(channel);
Metadata headers = new() { { "authorization", $"Bearer {apiKey}" } };
BrowseChildrenReply reply = await client.BrowseChildrenAsync(new BrowseChildrenRequest(), headers);
Assert.True(reply.CacheSequence > 0UL);
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
}
}
@@ -1,221 +0,0 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Tests for the <see cref="LazyBrowseNode"/> walker over the BrowseChildren RPC.
/// </summary>
public sealed class LazyBrowseNodeTests
{
/// <summary>
/// Verifies that calling BrowseAsync with no parent returns the root nodes
/// from the first BrowseChildren reply and surfaces the per-child has-children hint.
/// </summary>
[Fact]
public async Task Browse_NoParent_ReturnsRoots()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true), BuildObject(2, "Other")],
childHasChildren: [true, false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
Assert.Equal(2, roots.Count);
Assert.Equal("Plant", roots[0].Object.TagName);
Assert.True(roots[0].HasChildrenHint);
Assert.False(roots[0].IsExpanded);
Assert.Equal("Other", roots[1].Object.TagName);
Assert.False(roots[1].HasChildrenHint);
Assert.False(roots[1].IsExpanded);
}
/// <summary>
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
/// </summary>
[Fact]
public async Task Expand_PopulatesChildrenAndMarksExpanded()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(10, "Line1")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
Assert.True(roots[0].IsExpanded);
Assert.Single(roots[0].Children);
Assert.Equal("Line1", roots[0].Children[0].Object.TagName);
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
/// </summary>
[Fact]
public async Task Expand_CalledTwice_NoSecondRpc()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(10, "Line1")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
await roots[0].ExpandAsync();
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
/// </summary>
[Fact]
public async Task Expand_UnknownParent_ThrowsMxGatewayException()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
// Queue the failure for the upcoming ExpandAsync call so it consumes
// the exception on its first RPC rather than the BrowseAsync above.
transport.BrowseChildrenExceptions.Enqueue(
new MxGatewayException(
"Parent not found",
new RpcException(new Status(StatusCode.NotFound, "Parent not found"))));
await Assert.ThrowsAsync<MxGatewayException>(async () => await roots[0].ExpandAsync());
Assert.False(roots[0].IsExpanded);
Assert.Empty(roots[0].Children);
}
/// <summary>
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
/// </summary>
[Fact]
public async Task Expand_MultiPageSiblings_GathersAllPages()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
// Roots
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(7, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
// First child page (2 children) with a next token
BrowseChildrenReply childPage1 = BuildReply(
children: [BuildObject(70, "ChildA"), BuildObject(71, "ChildB")],
childHasChildren: [false, false],
cacheSequence: 1);
childPage1.NextPageToken = "7:abc:2";
transport.BrowseChildrenReplies.Enqueue(childPage1);
// Second child page (1 child) with no next token
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(72, "ChildC")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
Assert.Equal(3, roots[0].Children.Count);
Assert.Equal(3, transport.BrowseChildrenCalls.Count);
Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
}
/// <summary>
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
/// </summary>
[Fact]
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 7));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(2, "Mixer_001")],
childHasChildren: [false],
cacheSequence: 7));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
// Fire ten concurrent expands of the same node.
Task[] tasks = Enumerable.Range(0, 10)
.Select(_ => roots[0].ExpandAsync())
.ToArray();
await Task.WhenAll(tasks);
Assert.True(roots[0].IsExpanded);
Assert.Single(roots[0].Children);
// 1 roots fetch + exactly 1 expand fetch = 2 total
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
/// </summary>
[Fact]
public async Task Browse_WithFilter_ForwardsToRequest()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.BrowseAsync(new BrowseChildrenOptions
{
TagNameGlob = "Mixer*",
AlarmBearingOnly = true,
});
BrowseChildrenRequest request = Assert.Single(transport.BrowseChildrenCalls).Request;
Assert.Equal("Mixer*", request.TagNameGlob);
Assert.True(request.AlarmBearingOnly);
}
private static GalaxyObject BuildObject(int id, string tag, bool isArea = false)
=> new() { GobjectId = id, TagName = tag, BrowseName = tag, IsArea = isArea };
private static BrowseChildrenReply BuildReply(
IReadOnlyList<GalaxyObject> children,
IReadOnlyList<bool> childHasChildren,
ulong cacheSequence)
{
BrowseChildrenReply reply = new() { TotalChildCount = children.Count, CacheSequence = cacheSequence };
reply.Children.AddRange(children);
reply.ChildHasChildren.AddRange(childHasChildren);
return reply;
}
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
=> new(transport.Options, transport);
private static FakeGalaxyRepositoryTransport CreateTransport()
=> new(new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
});
}
@@ -1,85 +0,0 @@
using System.Net.Http;
using System.Net.Security;
using ZB.MOM.WW.MxGateway.Client;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientTlsHandlerTests
{
/// <summary>
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
/// the handler installs an accept-all callback so the gateway's self-signed cert is trusted.
/// The callback must return true regardless of chain errors.
/// </summary>
[Fact]
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
};
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
}
/// <summary>
/// Verifies that when RequireCertificateValidation is true, the callback is left null
/// so the OS trust store performs validation.
/// </summary>
[Fact]
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
RequireCertificateValidation = true,
};
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
}
}
public sealed class GalaxyRepositoryClientTlsHandlerTests
{
/// <summary>
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
/// the Galaxy client handler installs an accept-all callback so the gateway's self-signed cert is trusted.
/// The callback must return true regardless of chain errors.
/// </summary>
[Fact]
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
};
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
}
/// <summary>
/// Verifies that when RequireCertificateValidation is true, the Galaxy client callback is left null
/// so the OS trust store performs validation.
/// </summary>
[Fact]
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
RequireCertificateValidation = true,
};
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
}
}
@@ -1,11 +0,0 @@
<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>
@@ -1,26 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Filters and shape options for <see cref="GalaxyRepositoryClient.BrowseAsync(BrowseChildrenOptions, System.Threading.CancellationToken)"/>.
/// Mirror of <see cref="DiscoverHierarchyOptions"/> for the lazy-browse path.
/// </summary>
public sealed class BrowseChildrenOptions
{
/// <summary>Restrict to children whose Galaxy category is in this set.</summary>
public IReadOnlyList<int> CategoryIds { get; init; } = [];
/// <summary>Restrict to children 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 children that bear at least one alarm attribute.</summary>
public bool AlarmBearingOnly { get; init; }
/// <summary>Restrict to children that have at least one historized attribute.</summary>
public bool HistorizedOnly { get; init; }
}
@@ -1,43 +0,0 @@
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; }
}
@@ -1,101 +0,0 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// One node in a lazy-loaded Galaxy browse tree. Holds the underlying
/// <see cref="GalaxyObject"/> and exposes <see cref="ExpandAsync"/> to fetch
/// its direct children on demand. Expansion is one-shot: a second call is a
/// no-op. Pagination of large sibling sets is handled internally.
/// </summary>
public sealed class LazyBrowseNode
{
private readonly GalaxyRepositoryClient _client;
private readonly BrowseChildrenOptions _options;
private readonly List<LazyBrowseNode> _children = [];
private readonly SemaphoreSlim _expandLock = new(1, 1);
private bool _isExpanded;
internal LazyBrowseNode(
GalaxyRepositoryClient client,
GalaxyObject @object,
bool hasChildrenHint,
BrowseChildrenOptions options)
{
_client = client;
Object = @object;
HasChildrenHint = hasChildrenHint;
_options = options;
}
/// <summary>The underlying Galaxy object for this node.</summary>
public GalaxyObject Object { get; }
/// <summary>True when the server reports this node has at least one matching descendant.</summary>
public bool HasChildrenHint { get; }
/// <summary>Direct children loaded by <see cref="ExpandAsync"/>; empty until then.</summary>
public IReadOnlyList<LazyBrowseNode> Children => _children;
/// <summary>True after the first <see cref="ExpandAsync"/> call completes.</summary>
public bool IsExpanded => _isExpanded;
/// <summary>
/// Fetches direct children from the gateway and populates <see cref="Children"/>.
/// Idempotent: subsequent calls are no-ops.
/// </summary>
/// <remarks>
/// Thread-safe: concurrent callers see exactly one fetch; subsequent callers
/// (after the first completes) return immediately.
/// </remarks>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task ExpandAsync(CancellationToken cancellationToken = default)
{
if (_isExpanded)
{
return;
}
await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_isExpanded)
{
return;
}
string pageToken = string.Empty;
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do
{
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
request.ParentGobjectId = Object.GobjectId;
request.PageToken = pageToken;
BrowseChildrenReply reply = await _client
.BrowseChildrenRawAsync(request, cancellationToken)
.ConfigureAwait(false);
for (int i = 0; i < reply.Children.Count; i++)
{
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
_children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
}
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
_isExpanded = true;
}
finally
{
_expandLock.Release();
}
}
}
@@ -1,17 +0,0 @@
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
{
/// <inheritdoc cref="GatewayContractInfo.GatewayProtocolVersion"/>
public const uint GatewayProtocolVersion =
GatewayContractInfo.GatewayProtocolVersion;
/// <inheritdoc cref="GatewayContractInfo.WorkerProtocolVersion"/>
public const uint WorkerProtocolVersion =
GatewayContractInfo.WorkerProtocolVersion;
}
@@ -1,3 +0,0 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ZB.MOM.WW.MxGateway.Client.Tests")]
@@ -1,36 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\ZB.MOM.WW.MxGateway.Contracts\ZB.MOM.WW.MxGateway.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Polly.Core" Version="8.6.6" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<None Include="..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ZB.MOM.WW.MxGateway.Client.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
-17
View File
@@ -104,23 +104,6 @@ Support:
- `credentials.NewClientTLSFromFile`,
- custom `tls.Config` for advanced callers.
### Trust posture
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). To make that usable, TLS is **lenient by default**: when `Plaintext` is
`false` and no `CACertFile`/`TLSConfig`/`TransportCredentials` is supplied,
`buildCredentials` dials with `tls.Config{InsecureSkipVerify: true}` (carrying
`ServerNameOverride` as the SNI when set), so the gateway's self-signed
certificate is accepted without verification.
To verify the gateway instead:
- set `CACertFile` to pin a CA (full verification against that root), or
- set `RequireCertificateValidation: true` to verify against the OS/system trust
roots without pinning.
Pinning a CA always wins over the lenient default.
## Streaming
`Events(ctx)` should return a receive channel of:
+31 -111
View File
@@ -75,29 +75,42 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
})
```
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
the client is **lenient by default**: a TLS connection (`Plaintext: false`) with
no `CACertFile`/`TLSConfig` accepts whatever certificate the gateway presents
(`InsecureSkipVerify`, with `ServerNameOverride` as the SNI when set). To verify
instead, set `CACertFile` to pin a CA, or set `RequireCertificateValidation:
true` to verify against the OS/system trust roots without pinning. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
`Client.OpenSession` returns a `Session` with helpers for `Register`,
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
`AddItem`, `AddItem2`, `Advise`, `Write`, the full bulk family
(`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
returned subscription owns cancellation and exposes `Close` for deterministic
goroutine cleanup. Raw protobuf messages remain available through the
goroutine cleanup. `Events` and `EventsAfter` are a compatibility shim with a
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
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
errors preserve the raw reply.
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
call returns a `StreamAlarmsClient`; cancel its context to terminate the
stream. All three pass straight through to the gateway's central alarm
monitor.
`Dial` and `DialGalaxy` create the connection lazily (`grpc.NewClient`): a
gateway that is briefly unavailable no longer turns into a hard error — the
connection recovers once the gateway comes up. To keep fail-fast behavior,
both run a readiness probe bounded by `DialTimeout` (default 10s, or the
context deadline when sooner) and return a `*GatewayError` if the gateway
cannot be reached in that window.
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
@@ -129,68 +142,6 @@ reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
the generated `*GalaxyObject` slice with each object's dynamic attributes
populated for direct contract access.
### Browsing lazily
For UI trees or OPC UA bridges, use `BrowseChildren` to walk one level at a
time instead of loading the full hierarchy. Pass an empty request for root
objects; subsequent calls set `ParentGobjectId`, `ParentTagName`, or
`ParentContainedPath`. Filter fields match `DiscoverHierarchy`. Each response
pairs `Children` with `ChildHasChildren` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```go
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated/galaxy_repository/v1"
reply, err := galaxy.BrowseChildren(ctx, &pb.BrowseChildrenRequest{})
if err != nil {
return err
}
for i, child := range reply.GetChildren() {
fmt.Printf("%s expand=%v\n", child.GetTagName(), reply.GetChildHasChildren()[i])
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```go
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
if err != nil {
log.Fatal(err)
}
defer galaxy.Close()
roots, err := galaxy.Browse(ctx, nil)
if err != nil {
log.Fatal(err)
}
for _, root := range roots {
if root.HasChildrenHint() {
if err := root.Expand(ctx); err != nil {
log.Fatal(err)
}
}
for _, child := range root.Children() {
kind := "leaf"
if child.HasChildrenHint() {
kind = "has children"
}
fmt.Printf("%s (%s)\n", child.Object().GetTagName(), kind)
}
}
```
`Expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`Browse` again from the root.
### Watching deploy events
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
@@ -235,7 +186,8 @@ The CLI exposes the same RPC via `galaxy-watch`:
```powershell
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 -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:00Z # whole-second RFC 3339
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
```
@@ -283,38 +235,6 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
```
## Installing the Go client
The module is resolved directly from the git repo — no package registry:
````bash
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.0
````
Then import:
````go
import "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
````
If your build environment cannot reach `gitea.dohertylan.com` directly,
configure `GOPROXY` to point at an internal proxy that fronts the Gitea
repo, or use `GONOSUMCHECK` + `GOPRIVATE` to bypass the checksum database
for the internal module path.
## Releasing a new version
Go modules in monorepo subdirectories use prefixed tags. To tag a release
from this repo:
````bash
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
````
The script validates semver, refuses to tag with uncommitted tracked
changes, creates an annotated tag `clients/go/v0.1.1`, and (with `-Push`)
pushes it to origin.
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
+63 -75
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")
}
handles, err := parseInt32List(*itemHandles)
if err != nil {
return err
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
handles, err := parseInt32List(*itemHandles)
if err != nil {
return err
}
session := mxgateway.NewSessionForID(client, *sessionID)
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), handles)
return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err)
@@ -412,15 +412,14 @@ func runWriteSecured2Bulk(ctx context.Context, args []string, stdout, stderr io.
}
// runWriteBulkVariant shares the flag-parsing + entry-build skeleton across
// the four bulk-write families. The variant is derived from command alone;
// withTimestamp adds a --timestamp-value flag. To keep wrong-variant flags
// from silently no-op'ing, secured-only flags (-current-user-id /
// -verifier-user-id) are only registered for the secured variants, and
// -user-id only for the non-secured Write/Write2 variants — a wrong-variant
// flag then surfaces as a clean "flag provided but not defined" error.
// the four bulk-write families. command selects which of the four routes
// runs; withTimestamp adds a --timestamp-value flag for the Write2 / Secured2
// variants. Secured-only flags (--current-user-id / --verifier-user-id) are
// 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.SetOutput(stderr)
common := bindCommonFlags(flags)
@@ -430,11 +429,7 @@ func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.W
itemHandles := flags.String("item-handles", "", "comma-separated item handles")
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
values := flags.String("values", "", "comma-separated values (one per item handle)")
var (
userID *int
currentUserID *int
verifierUserID *int
)
var userID, currentUserID, verifierUserID *int
if secured {
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)")
@@ -531,16 +526,6 @@ func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.W
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:
// 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
@@ -611,16 +596,19 @@ func runBenchReadBulk(ctx context.Context, args []string, stdout, stderr io.Writ
}()
// Warm-up: drive identical calls so any first-call JIT / connection-pool
// setup is amortised before the measurement window opens. The ctx.Err()
// guard short-circuits on Ctrl+C / parent-cancel instead of spinning
// failing ReadBulk calls until the wall-clock deadline elapses.
// setup is amortised before the measurement window opens. Honor ctx so
// 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)
timeout := time.Duration(*timeoutMs) * time.Millisecond
for time.Now().Before(warmupDeadline) && ctx.Err() == nil {
_, _ = session.ReadBulk(ctx, serverHandle, tags, timeout)
}
// Steady state: per-call latency captured via time.Now() deltas.
// Steady state: per-call latency captured via time.Now() deltas. Same ctx
// guard as warm-up; on cancel we stop the loop and report the truncated
// window faithfully.
latenciesMs := make([]float64, 0, 65536)
var totalReadResults int64
var cachedReadResults int64
@@ -688,7 +676,7 @@ func percentileSummary(sample []float64) map[string]float64 {
sorted := append([]float64(nil), sample...)
sort.Float64s(sorted)
mean := 0.0
maxValue := sorted[len(sorted)-1]
max := sorted[len(sorted)-1]
for _, v := range sample {
mean += v
}
@@ -697,7 +685,7 @@ func percentileSummary(sample []float64) map[string]float64 {
"p50": roundTo(percentile(sorted, 0.50), 3),
"p95": roundTo(percentile(sorted, 0.95), 3),
"p99": roundTo(percentile(sorted, 0.99), 3),
"max": roundTo(maxValue, 3),
"max": roundTo(max, 3),
"mean": roundTo(mean, 3),
}
}
@@ -729,6 +717,16 @@ func roundTo(value float64, digits int) float64 {
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 {
flags := flag.NewFlagSet("write", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -786,8 +784,15 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
}
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)
streamCtx, cancelStream := context.WithCancel(ctx)
streamCtx, cancelStream := context.WithCancel(signalCtx)
defer cancelStream()
subscription, err := session.SubscribeEventsAfter(streamCtx, *after)
if err != nil {
@@ -1083,31 +1088,31 @@ func parseValue(valueType, valueText string) (*mxgateway.MxValue, error) {
case "bool":
value, err := strconv.ParseBool(valueText)
if err != nil {
return nil, err
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err)
}
return mxgateway.BoolValue(value), nil
case "int32":
value, err := strconv.ParseInt(valueText, 10, 32)
if err != nil {
return nil, err
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err)
}
return mxgateway.Int32Value(int32(value)), nil
case "int64":
value, err := strconv.ParseInt(valueText, 10, 64)
if err != nil {
return nil, err
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err)
}
return mxgateway.Int64Value(value), nil
case "float":
value, err := strconv.ParseFloat(valueText, 32)
if err != nil {
return nil, err
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err)
}
return mxgateway.FloatValue(float32(value)), nil
case "double":
value, err := strconv.ParseFloat(valueText, 64)
if err != nil {
return nil, err
return nil, fmt.Errorf("invalid -value for -type %s: %q: %w", valueType, valueText, err)
}
return mxgateway.DoubleValue(value), nil
case "string":
@@ -1195,10 +1200,6 @@ type protojsonMessage interface {
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
// in batch mode, regardless of success or failure.
const batchEOR = "__MXGW_BATCH_EOR__"
@@ -1206,28 +1207,18 @@ const batchEOR = "__MXGW_BATCH_EOR__"
// runBatch reads one command line at a time from in, dispatches each via the
// normal runWithIO routing, and writes a batchEOR sentinel to stdout after
// every result. Errors are serialised as JSON to stdout (not stderr) so the
// harness can parse them without interleaving stderr. Blank lines are
// skipped; only stdin EOF ends the session.
//
// The scanner buffer is widened to 16 MiB so a single long command line
// (e.g. a bulk-write with several thousand handles) does not trip the
// default 64 KiB bufio.Scanner token-too-long error and abort the session.
// If a line still exceeds the cap, the error is surfaced as a per-command
// error-with-sentinel and the session continues.
// harness can parse them without interleaving stderr. The loop never terminates
// on command error; only stdin EOF (or an empty line) ends the session.
func runBatch(ctx context.Context, in io.Reader, stdout, stderr io.Writer) error {
bw := bufio.NewWriter(stdout)
scanner := bufio.NewScanner(in)
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for {
if !scanner.Scan() {
for scanner.Scan() {
line := scanner.Text()
if line == "" {
break
}
line := scanner.Text()
args := strings.Fields(line)
if len(args) == 0 {
// Skip blank / whitespace-only lines; do NOT terminate. The
// session ends only on stdin EOF so a stray blank line in a
// PowerShell here-string does not silently drop later commands.
continue
}
if err := runWithIO(ctx, args, bw, stderr); err != nil {
@@ -1242,21 +1233,11 @@ func runBatch(ctx context.Context, in io.Reader, stdout, stderr io.Writer) error
_, _ = fmt.Fprintln(bw, batchEOR)
_ = bw.Flush()
}
if err := scanner.Err(); err != nil {
// Emit the scanner failure as a final error-with-sentinel so the
// harness sees the failure framed, then return the error so the
// process exit reflects it. This handles bufio.ErrTooLong for any
// pathological line above the 16 MiB cap.
errPayload := map[string]string{
"error": err.Error(),
"type": "error",
}
_ = writeJSON(bw, errPayload)
_, _ = fmt.Fprintln(bw, batchEOR)
_ = bw.Flush()
return err
}
return nil
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) {
@@ -1388,7 +1369,7 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
lastSeen := flags.String("last-seen-deploy-time", "", "RFC3339 timestamp; when set, suppresses the bootstrap event")
lastSeen := flags.String("last-seen-deploy-time", "", "RFC 3339 timestamp (with optional fractional seconds); when set, suppresses the bootstrap event")
limit := flags.Int("limit", 0, "maximum events to read; 0 means unbounded (Ctrl+C to stop)")
if err := flags.Parse(args); err != nil {
@@ -1397,7 +1378,11 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
var lastSeenPtr *time.Time
if *lastSeen != "" {
parsed, err := time.Parse(time.RFC3339, *lastSeen)
// Use RFC3339Nano so values copy-pasted from galaxy-watch -json output
// (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 {
return fmt.Errorf("invalid -last-seen-deploy-time: %w", err)
}
@@ -1440,6 +1425,9 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
count++
if *limit > 0 && count >= *limit {
cancelStream()
// Allow goroutine to drain.
for range events {
}
return nil
}
case streamErr, ok := <-errs:
+161 -206
View File
@@ -2,15 +2,10 @@ package main
import (
"bytes"
"context"
"encoding/json"
"net"
"errors"
"strings"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
)
func TestRunVersionJSON(t *testing.T) {
@@ -53,34 +48,6 @@ 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) {
value, err := parseValue("int32", "123")
if err != nil {
@@ -91,206 +58,194 @@ func TestParseValueBuildsTypedValue(t *testing.T) {
}
}
// TestRunWriteBulkVariantGatesSecuredFlags pins the Client.Go-022 fix:
// secured-only flags must be unavailable on non-secured variants, and
// vice-versa, so a wrong-variant flag fails with a clean "flag provided
// but not defined" error instead of silently no-op'ing.
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
}{
{
name: "write-bulk-rejects-current-user-id",
args: []string{"write-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write-bulk-rejects-verifier-user-id",
args: []string{"write-bulk", "-verifier-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write2-bulk-rejects-current-user-id",
args: []string{"write2-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write-secured-bulk-rejects-user-id",
args: []string{"write-secured-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write-secured2-bulk-rejects-user-id",
args: []string{"write-secured2-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{"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) returned no error", tc.args)
t.Fatalf("runWithIO(%v) error = nil, want validation error", tc.args)
}
if !strings.Contains(err.Error(), "flag provided but not defined") {
t.Fatalf("runWithIO(%v) error = %v; want 'flag provided but not defined'", tc.args, err)
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())
}
})
}
}
// TestRunBenchReadBulkRespectsContextCancellation pins the Client.Go-023
// fix: the warm-up and steady-state wall-clock loops must honour ctx.Err()
// so an external cancel (Ctrl+C, parent-cancel from a cross-language bench
// driver) short-circuits the bench instead of spinning failing ReadBulk
// calls until the wall-clock deadline elapses.
func TestRunBenchReadBulkRespectsContextCancellation(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := grpc.NewServer()
fake := &benchFakeGateway{}
pb.RegisterMxAccessGatewayServer(server, fake)
go func() {
_ = server.Serve(listener)
}()
defer server.Stop()
defer listener.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Long warm-up + duration, so if the ctx.Err() guard were missing the
// loops would run for ~10s. With the guard, the cancel below short-
// circuits both loops within ~one ReadBulk iteration.
args := []string{
"bench-read-bulk",
"-endpoint", listener.Addr().String(),
"-plaintext",
"-api-key", "test",
"-warmup-seconds", "5",
"-duration-seconds", "5",
"-bulk-size", "1",
"-timeout-ms", "100",
}
// Cancel after a brief delay — far less than warmup+duration (10s).
go func() {
time.Sleep(150 * time.Millisecond)
cancel()
}()
var stdout, stderr bytes.Buffer
start := time.Now()
err = runWithIO(ctx, args, &stdout, &stderr)
elapsed := time.Since(start)
// With the ctx.Err() guard, the loops exit well before the wall-clock
// deadlines (warmup=5s + duration=5s = 10s). Allow generous slack for
// CI noise but assert clearly less than the un-guarded worst case.
if elapsed > 4*time.Second {
t.Fatalf("bench-read-bulk took %s after ctx cancel; want <4s (ctx.Err() guard missing?). err=%v stderr=%s", elapsed, err, stderr.String())
}
}
// benchFakeGateway is a minimal MxAccessGatewayServer that satisfies the
// bench-read-bulk session-setup sequence (OpenSession + Invoke for Register
// / SubscribeBulk / ReadBulk / UnsubscribeBulk / CloseSession).
type benchFakeGateway struct {
pb.UnimplementedMxAccessGatewayServer
}
func (g *benchFakeGateway) OpenSession(_ context.Context, _ *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
return &pb.OpenSessionReply{
SessionId: "bench-session",
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}, nil
}
func (g *benchFakeGateway) CloseSession(_ context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
return &pb.CloseSessionReply{
SessionId: req.GetSessionId(),
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}, nil
}
func (g *benchFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
kind := req.GetCommand().GetKind()
reply := &pb.MxCommandReply{
SessionId: req.GetSessionId(),
Kind: kind,
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}
switch kind {
case pb.MxCommandKind_MX_COMMAND_KIND_REGISTER:
reply.Payload = &pb.MxCommandReply_Register{Register: &pb.RegisterReply{ServerHandle: 1}}
case pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK:
reply.Payload = &pb.MxCommandReply_SubscribeBulk{SubscribeBulk: &pb.BulkSubscribeReply{
Results: []*pb.SubscribeResult{{ServerHandle: 1, ItemHandle: 1, WasSuccessful: true}},
}}
case pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK:
reply.Payload = &pb.MxCommandReply_ReadBulk{ReadBulk: &pb.BulkReadReply{
Results: []*pb.BulkReadResult{{ItemHandle: 1, WasSuccessful: true, WasCached: true}},
}}
case pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK:
reply.Payload = &pb.MxCommandReply_UnsubscribeBulk{UnsubscribeBulk: &pb.BulkSubscribeReply{}}
}
return reply, nil
}
// TestRunBenchReadBulkRejectsNonPositiveBulkSize pins the Client.Go-023-adjacent
// positivity checks so they cannot drift while resolving the cancellation finding.
// 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", "-bulk-size", "0"}, &stdout, &stderr)
if err == nil || !strings.Contains(err.Error(), "bulk-size must be positive") {
t.Fatalf("bench-read-bulk -bulk-size 0 error = %v", err)
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())
}
}
// TestRunBatchSkipsBlankLinesAndContinuesUntilEOF pins the Client.Go-027 fix:
// a blank line in the middle of a batch session must NOT terminate the loop —
// only stdin EOF ends the session.
func TestRunBatchSkipsBlankLinesAndContinuesUntilEOF(t *testing.T) {
// TestRunBenchReadBulkRejectsNonPositiveDuration pins the duration-seconds>=1
// check at runBenchReadBulk's flag-parsing stage.
func TestRunBenchReadBulkRejectsNonPositiveDuration(t *testing.T) {
var stdout, stderr bytes.Buffer
// version -> blank -> version (a stray blank line in the middle of a
// programmatic session).
in := strings.NewReader("version --json\n\nversion --json\n")
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
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")
}
out := stdout.String()
// Both version commands must have produced a result before the EOR sentinel.
if count := strings.Count(out, batchEOR); count != 2 {
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, blank line skipped); out = %q", count, out)
if !strings.Contains(err.Error(), "duration-seconds must be positive") {
t.Fatalf("runWithIO error = %q, want 'duration-seconds must be positive'", err.Error())
}
}
// TestRunBatchHandlesLongCommandLine pins the Client.Go-026 fix: a command
// line longer than the default bufio.Scanner token size (64 KiB) must not
// abort the batch session.
func TestRunBatchHandlesLongCommandLine(t *testing.T) {
// 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
// Build a single command line larger than 64 KiB. The command itself is
// invalid (no real session) but runBatch must still emit an EOR sentinel
// and continue to the next command rather than dropping the line on the
// floor with a bufio.ErrTooLong from the outer return.
huge := strings.Repeat("tag-with-a-reasonably-long-name-and-suffix,", 2000) + "trailing"
line := "subscribe-bulk -session-id none -items " + huge
if len(line) <= 64*1024 {
t.Fatalf("test setup error: long line length = %d, want > 64KiB", len(line))
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")
}
in := strings.NewReader(line + "\nversion --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()
// Both commands must produce an EOR sentinel — the long line should be a
// per-command error (still emitted with EOR), then the version command
// should run normally.
if count := strings.Count(out, batchEOR); count != 2 {
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, even when first is too long); out length = %d", count, len(out))
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'
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
$protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos'
$protoRoot = Join-Path $repoRoot 'src\MxGateway.Contracts\Protos'
$outputRoot = Join-Path $PSScriptRoot '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'
@@ -824,260 +824,6 @@ func (x *GalaxyAttribute) GetIsAlarm() bool {
return false
}
type BrowseChildrenRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Parent selector. Empty oneof returns root objects (parent_gobject_id == 0).
//
// Types that are valid to be assigned to Parent:
//
// *BrowseChildrenRequest_ParentGobjectId
// *BrowseChildrenRequest_ParentTagName
// *BrowseChildrenRequest_ParentContainedPath
Parent isBrowseChildrenRequest_Parent `protobuf_oneof:"parent"`
// Maximum number of direct children to return. Server default 500; cap 5000.
PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
// Opaque token returned by a previous BrowseChildren response. Bound to the
// cache sequence, parent selector, and the filter set; a mismatch returns
// InvalidArgument.
PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
// --- Filter parity with DiscoverHierarchy. AND-combined. ---
CategoryIds []int32 `protobuf:"varint,6,rep,packed,name=category_ids,json=categoryIds,proto3" json:"category_ids,omitempty"`
TemplateChainContains []string `protobuf:"bytes,7,rep,name=template_chain_contains,json=templateChainContains,proto3" json:"template_chain_contains,omitempty"`
TagNameGlob string `protobuf:"bytes,8,opt,name=tag_name_glob,json=tagNameGlob,proto3" json:"tag_name_glob,omitempty"`
IncludeAttributes *bool `protobuf:"varint,9,opt,name=include_attributes,json=includeAttributes,proto3,oneof" json:"include_attributes,omitempty"`
AlarmBearingOnly bool `protobuf:"varint,10,opt,name=alarm_bearing_only,json=alarmBearingOnly,proto3" json:"alarm_bearing_only,omitempty"`
HistorizedOnly bool `protobuf:"varint,11,opt,name=historized_only,json=historizedOnly,proto3" json:"historized_only,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BrowseChildrenRequest) Reset() {
*x = BrowseChildrenRequest{}
mi := &file_galaxy_repository_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BrowseChildrenRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BrowseChildrenRequest) ProtoMessage() {}
func (x *BrowseChildrenRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BrowseChildrenRequest.ProtoReflect.Descriptor instead.
func (*BrowseChildrenRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{10}
}
func (x *BrowseChildrenRequest) GetParent() isBrowseChildrenRequest_Parent {
if x != nil {
return x.Parent
}
return nil
}
func (x *BrowseChildrenRequest) GetParentGobjectId() int32 {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentGobjectId); ok {
return x.ParentGobjectId
}
}
return 0
}
func (x *BrowseChildrenRequest) GetParentTagName() string {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentTagName); ok {
return x.ParentTagName
}
}
return ""
}
func (x *BrowseChildrenRequest) GetParentContainedPath() string {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentContainedPath); ok {
return x.ParentContainedPath
}
}
return ""
}
func (x *BrowseChildrenRequest) GetPageSize() int32 {
if x != nil {
return x.PageSize
}
return 0
}
func (x *BrowseChildrenRequest) GetPageToken() string {
if x != nil {
return x.PageToken
}
return ""
}
func (x *BrowseChildrenRequest) GetCategoryIds() []int32 {
if x != nil {
return x.CategoryIds
}
return nil
}
func (x *BrowseChildrenRequest) GetTemplateChainContains() []string {
if x != nil {
return x.TemplateChainContains
}
return nil
}
func (x *BrowseChildrenRequest) GetTagNameGlob() string {
if x != nil {
return x.TagNameGlob
}
return ""
}
func (x *BrowseChildrenRequest) GetIncludeAttributes() bool {
if x != nil && x.IncludeAttributes != nil {
return *x.IncludeAttributes
}
return false
}
func (x *BrowseChildrenRequest) GetAlarmBearingOnly() bool {
if x != nil {
return x.AlarmBearingOnly
}
return false
}
func (x *BrowseChildrenRequest) GetHistorizedOnly() bool {
if x != nil {
return x.HistorizedOnly
}
return false
}
type isBrowseChildrenRequest_Parent interface {
isBrowseChildrenRequest_Parent()
}
type BrowseChildrenRequest_ParentGobjectId struct {
ParentGobjectId int32 `protobuf:"varint,1,opt,name=parent_gobject_id,json=parentGobjectId,proto3,oneof"`
}
type BrowseChildrenRequest_ParentTagName struct {
ParentTagName string `protobuf:"bytes,2,opt,name=parent_tag_name,json=parentTagName,proto3,oneof"`
}
type BrowseChildrenRequest_ParentContainedPath struct {
ParentContainedPath string `protobuf:"bytes,3,opt,name=parent_contained_path,json=parentContainedPath,proto3,oneof"`
}
func (*BrowseChildrenRequest_ParentGobjectId) isBrowseChildrenRequest_Parent() {}
func (*BrowseChildrenRequest_ParentTagName) isBrowseChildrenRequest_Parent() {}
func (*BrowseChildrenRequest_ParentContainedPath) isBrowseChildrenRequest_Parent() {}
type BrowseChildrenReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Direct children matching the filter, sorted areas-first then by
// case-insensitive display name (same order as the dashboard tree).
Children []*GalaxyObject `protobuf:"bytes,1,rep,name=children,proto3" json:"children,omitempty"`
// Non-empty when another page of siblings is available.
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
// Total matching direct children of the parent (post-filter).
TotalChildCount int32 `protobuf:"varint,3,opt,name=total_child_count,json=totalChildCount,proto3" json:"total_child_count,omitempty"`
// Parallel array, indexed with `children`. True when the child has at least
// one matching descendant under the same filter set. Lets a UI choose
// whether to draw an expand triangle without an extra round trip.
ChildHasChildren []bool `protobuf:"varint,4,rep,packed,name=child_has_children,json=childHasChildren,proto3" json:"child_has_children,omitempty"`
// Cache sequence this reply was projected from. Clients may pass it back as
// part of the page_token contract. Mismatch on the next page -> InvalidArgument.
CacheSequence uint64 `protobuf:"varint,5,opt,name=cache_sequence,json=cacheSequence,proto3" json:"cache_sequence,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BrowseChildrenReply) Reset() {
*x = BrowseChildrenReply{}
mi := &file_galaxy_repository_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BrowseChildrenReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BrowseChildrenReply) ProtoMessage() {}
func (x *BrowseChildrenReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BrowseChildrenReply.ProtoReflect.Descriptor instead.
func (*BrowseChildrenReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{11}
}
func (x *BrowseChildrenReply) GetChildren() []*GalaxyObject {
if x != nil {
return x.Children
}
return nil
}
func (x *BrowseChildrenReply) GetNextPageToken() string {
if x != nil {
return x.NextPageToken
}
return ""
}
func (x *BrowseChildrenReply) GetTotalChildCount() int32 {
if x != nil {
return x.TotalChildCount
}
return 0
}
func (x *BrowseChildrenReply) GetChildHasChildren() []bool {
if x != nil {
return x.ChildHasChildren
}
return nil
}
func (x *BrowseChildrenReply) GetCacheSequence() uint64 {
if x != nil {
return x.CacheSequence
}
return 0
}
var File_galaxy_repository_proto protoreflect.FileDescriptor
const file_galaxy_repository_proto_rawDesc = "" +
@@ -1151,35 +897,12 @@ const file_galaxy_repository_proto_rawDesc = "" +
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
"\ris_historized\x18\n" +
" \x01(\bR\fisHistorized\x12\x19\n" +
"\bis_alarm\x18\v \x01(\bR\aisAlarm\"\x8c\x04\n" +
"\x15BrowseChildrenRequest\x12,\n" +
"\x11parent_gobject_id\x18\x01 \x01(\x05H\x00R\x0fparentGobjectId\x12(\n" +
"\x0fparent_tag_name\x18\x02 \x01(\tH\x00R\rparentTagName\x124\n" +
"\x15parent_contained_path\x18\x03 \x01(\tH\x00R\x13parentContainedPath\x12\x1b\n" +
"\tpage_size\x18\x04 \x01(\x05R\bpageSize\x12\x1d\n" +
"\n" +
"page_token\x18\x05 \x01(\tR\tpageToken\x12!\n" +
"\fcategory_ids\x18\x06 \x03(\x05R\vcategoryIds\x126\n" +
"\x17template_chain_contains\x18\a \x03(\tR\x15templateChainContains\x12\"\n" +
"\rtag_name_glob\x18\b \x01(\tR\vtagNameGlob\x122\n" +
"\x12include_attributes\x18\t \x01(\bH\x01R\x11includeAttributes\x88\x01\x01\x12,\n" +
"\x12alarm_bearing_only\x18\n" +
" \x01(\bR\x10alarmBearingOnly\x12'\n" +
"\x0fhistorized_only\x18\v \x01(\bR\x0ehistorizedOnlyB\b\n" +
"\x06parentB\x15\n" +
"\x13_include_attributes\"\xfe\x01\n" +
"\x13BrowseChildrenReply\x12>\n" +
"\bchildren\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\bchildren\x12&\n" +
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12*\n" +
"\x11total_child_count\x18\x03 \x01(\x05R\x0ftotalChildCount\x12,\n" +
"\x12child_has_children\x18\x04 \x03(\bR\x10childHasChildren\x12%\n" +
"\x0ecache_sequence\x18\x05 \x01(\x04R\rcacheSequence2\xb6\x04\n" +
"\bis_alarm\x18\v \x01(\bR\aisAlarm2\xcc\x03\n" +
"\x10GalaxyRepository\x12h\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" +
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n" +
"\x0eBrowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3"
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3"
var (
file_galaxy_repository_proto_rawDescOnce sync.Once
@@ -1193,7 +916,7 @@ func file_galaxy_repository_proto_rawDescGZIP() []byte {
return file_galaxy_repository_proto_rawDescData
}
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_galaxy_repository_proto_goTypes = []any{
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
@@ -1205,35 +928,30 @@ var file_galaxy_repository_proto_goTypes = []any{
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
(*BrowseChildrenRequest)(nil), // 10: galaxy_repository.v1.BrowseChildrenRequest
(*BrowseChildrenReply)(nil), // 11: galaxy_repository.v1.BrowseChildrenReply
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
(*wrapperspb.Int32Value)(nil), // 13: google.protobuf.Int32Value
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
(*wrapperspb.Int32Value)(nil), // 11: google.protobuf.Int32Value
}
var file_galaxy_repository_proto_depIdxs = []int32{
12, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
13, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
10, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
11, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
12, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
12, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
12, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
10, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
10, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
10, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
8, // 7: galaxy_repository.v1.BrowseChildrenReply.children:type_name -> galaxy_repository.v1.GalaxyObject
0, // 8: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
2, // 9: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
4, // 10: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
6, // 11: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
10, // 12: galaxy_repository.v1.GalaxyRepository.BrowseChildren:input_type -> galaxy_repository.v1.BrowseChildrenRequest
1, // 13: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
3, // 14: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
5, // 15: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
7, // 16: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
11, // 17: galaxy_repository.v1.GalaxyRepository.BrowseChildren:output_type -> galaxy_repository.v1.BrowseChildrenReply
13, // [13:18] is the sub-list for method output_type
8, // [8:13] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
0, // 7: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
2, // 8: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
4, // 9: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
6, // 10: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
1, // 11: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
3, // 12: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
5, // 13: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
7, // 14: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
11, // [11:15] is the sub-list for method output_type
7, // [7:11] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_galaxy_repository_proto_init() }
@@ -1246,18 +964,13 @@ func file_galaxy_repository_proto_init() {
(*DiscoverHierarchyRequest_RootTagName)(nil),
(*DiscoverHierarchyRequest_RootContainedPath)(nil),
}
file_galaxy_repository_proto_msgTypes[10].OneofWrappers = []any{
(*BrowseChildrenRequest_ParentGobjectId)(nil),
(*BrowseChildrenRequest_ParentTagName)(nil),
(*BrowseChildrenRequest_ParentContainedPath)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
NumEnums: 0,
NumMessages: 12,
NumMessages: 10,
NumExtensions: 0,
NumServices: 1,
},
@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.1
// source: galaxy_repository.proto
@@ -23,7 +23,6 @@ const (
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
GalaxyRepository_BrowseChildren_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/BrowseChildren"
)
// GalaxyRepositoryClient is the client API for GalaxyRepository service.
@@ -45,11 +44,6 @@ type GalaxyRepositoryClient interface {
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
// Returns the direct children of a parent object (or the root objects when
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
// one level at a time instead of paging the full hierarchy. Filters mirror
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error)
}
type galaxyRepositoryClient struct {
@@ -109,16 +103,6 @@ func (c *galaxyRepositoryClient) WatchDeployEvents(ctx context.Context, in *Watc
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
func (c *galaxyRepositoryClient) BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BrowseChildrenReply)
err := c.cc.Invoke(ctx, GalaxyRepository_BrowseChildren_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GalaxyRepositoryServer is the server API for GalaxyRepository service.
// All implementations must embed UnimplementedGalaxyRepositoryServer
// for forward compatibility.
@@ -138,11 +122,6 @@ type GalaxyRepositoryServer interface {
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
// Returns the direct children of a parent object (or the root objects when
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
// one level at a time instead of paging the full hierarchy. Filters mirror
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error)
mustEmbedUnimplementedGalaxyRepositoryServer()
}
@@ -165,9 +144,6 @@ func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *D
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
}
func (UnimplementedGalaxyRepositoryServer) BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error) {
return nil, status.Error(codes.Unimplemented, "method BrowseChildren not implemented")
}
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
@@ -254,24 +230,6 @@ func _GalaxyRepository_WatchDeployEvents_Handler(srv interface{}, stream grpc.Se
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
func _GalaxyRepository_BrowseChildren_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BrowseChildrenRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_BrowseChildren_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, req.(*BrowseChildrenRequest))
}
return interceptor(ctx, in, info, handler)
}
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -291,10 +249,6 @@ var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
MethodName: "DiscoverHierarchy",
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
},
{
MethodName: "BrowseChildren",
Handler: _GalaxyRepository_BrowseChildren_Handler,
},
},
Streams: []grpc.StreamDesc{
{
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.1
// source: mxaccess_gateway.proto
@@ -19,13 +19,12 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
MxAccessGateway_OpenSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/OpenSession"
MxAccessGateway_CloseSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/CloseSession"
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
MxAccessGateway_StreamAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms"
MxAccessGateway_QueryActiveAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms"
MxAccessGateway_OpenSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/OpenSession"
MxAccessGateway_CloseSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/CloseSession"
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
MxAccessGateway_StreamAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms"
)
// MxAccessGatewayClient is the client API for MxAccessGateway service.
@@ -45,15 +44,6 @@ type MxAccessGatewayClient interface {
// Served by the gateway's always-on alarm monitor; any number of clients
// fan out from the single monitor without opening a worker session.
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.
// `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.
QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error)
}
type mxAccessGatewayClient struct {
@@ -142,25 +132,6 @@ 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.
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.
// All implementations must embed UnimplementedMxAccessGatewayServer
// for forward compatibility.
@@ -178,15 +149,6 @@ type MxAccessGatewayServer interface {
// Served by the gateway's always-on alarm monitor; any number of clients
// fan out from the single monitor without opening a worker session.
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.
// `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.
QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error
mustEmbedUnimplementedMxAccessGatewayServer()
}
@@ -215,9 +177,6 @@ func (UnimplementedMxAccessGatewayServer) AcknowledgeAlarm(context.Context, *Ack
func (UnimplementedMxAccessGatewayServer) StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error {
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) testEmbeddedByValue() {}
@@ -333,17 +292,6 @@ 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.
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.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -379,11 +327,6 @@ var MxAccessGateway_ServiceDesc = grpc.ServiceDesc{
Handler: _MxAccessGateway_StreamAlarms_Handler,
ServerStreams: true,
},
{
StreamName: "QueryActiveAlarms",
Handler: _MxAccessGateway_QueryActiveAlarms_Handler,
ServerStreams: true,
},
},
Metadata: "mxaccess_gateway.proto",
}
@@ -1179,7 +1179,7 @@ const file_mxaccess_worker_proto_rawDesc = "" +
"\x1eWORKER_FAULT_CATEGORY_STA_HUNG\x10\t\x12(\n" +
"$WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW\x10\n" +
"\x12*\n" +
"&WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT\x10\vB&\xaa\x02#ZB.MOM.WW.MxGateway.Contracts.Protob\x06proto3"
"&WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT\x10\vB\x1c\xaa\x02\x19MxGateway.Contracts.Protob\x06proto3"
var (
file_mxaccess_worker_proto_rawDescOnce sync.Once
-21
View File
@@ -31,27 +31,6 @@ func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequ
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
// with one AlarmFeedMessage per currently-active alarm (the ConditionRefresh
// snapshot), then a single snapshot-complete sentinel, then a transition for
+42 -117
View File
@@ -14,8 +14,7 @@ import (
"google.golang.org/grpc/test/bufconn"
)
// PR E.4 — pins the Go SDK surface for the new alarm RPCs:
// AcknowledgeAlarm + QueryActiveAlarms.
// Pins the Go SDK surface for the alarm RPCs: AcknowledgeAlarm + StreamAlarms.
func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
fake := &fakeGatewayWithAlarms{
@@ -62,10 +61,6 @@ func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
defer cleanup()
_, 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 {
t.Fatalf("AcknowledgeAlarm(nil) returned no error")
}
@@ -94,7 +89,7 @@ func TestAcknowledgeAlarmMapsUnauthenticated(t *testing.T) {
}
}
func TestQueryActiveAlarmsStreamsSnapshots(t *testing.T) {
func TestStreamAlarmsStreamsSnapshotThenSnapshotComplete(t *testing.T) {
fake := &fakeGatewayWithAlarms{
activeSnapshots: []*pb.ActiveAlarmSnapshot{
{
@@ -112,86 +107,7 @@ func TestQueryActiveAlarmsStreamsSnapshots(t *testing.T) {
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
SessionId: "session-1",
})
if err != nil {
t.Fatalf("QueryActiveAlarms() error = %v", err)
}
var received []*pb.ActiveAlarmSnapshot
for {
snap, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("stream.Recv() error = %v", err)
}
received = append(received, snap)
}
if len(received) != 2 {
t.Fatalf("snapshot count = %d, want 2", len(received))
}
if received[0].GetAlarmFullReference() != "Tank01.Level.HiHi" {
t.Fatalf("snapshot[0] ref = %q", received[0].GetAlarmFullReference())
}
if received[1].GetCurrentState() != pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED {
t.Fatalf("snapshot[1] state = %v", received[1].GetCurrentState())
}
}
func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
fake := &fakeGatewayWithAlarms{}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
SessionId: "session-1",
AlarmFilterPrefix: "Tank01.",
})
if err != nil {
t.Fatalf("QueryActiveAlarms() error = %v", err)
}
for {
_, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("stream.Recv() error = %v", err)
}
}
if got := fake.queryRequest.GetAlarmFilterPrefix(); got != "Tank01." {
t.Fatalf("captured filter prefix = %q", got)
}
}
func TestStreamAlarmsPassesFilterPrefixAndReceivesFeedMessages(t *testing.T) {
fake := &fakeGatewayWithAlarms{
feedMessages: []*pb.AlarmFeedMessage{
{
Payload: &pb.AlarmFeedMessage_ActiveAlarm{
ActiveAlarm: &pb.ActiveAlarmSnapshot{
AlarmFullReference: "Tank01.Level.HiHi",
CurrentState: pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE,
},
},
},
{
Payload: &pb.AlarmFeedMessage_SnapshotComplete{
SnapshotComplete: true,
},
},
},
}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{
AlarmFilterPrefix: "Tank01.",
})
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{})
if err != nil {
t.Fatalf("StreamAlarms() error = %v", err)
}
@@ -207,24 +123,43 @@ func TestStreamAlarmsPassesFilterPrefixAndReceivesFeedMessages(t *testing.T) {
}
received = append(received, msg)
}
if len(received) != 2 {
t.Fatalf("received count = %d, want 2", len(received))
if len(received) != 3 {
t.Fatalf("message count = %d, want 3", len(received))
}
if got := fake.streamRequest.GetAlarmFilterPrefix(); got != "Tank01." {
t.Fatalf("captured filter prefix = %q", got)
if received[0].GetActiveAlarm().GetAlarmFullReference() != "Tank01.Level.HiHi" {
t.Fatalf("message[0] ref = %q", received[0].GetActiveAlarm().GetAlarmFullReference())
}
if got := fake.streamAuth; got != "Bearer test-api-key" {
t.Fatalf("stream authorization metadata = %q", got)
if received[1].GetActiveAlarm().GetCurrentState() != pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED {
t.Fatalf("message[1] state = %v", received[1].GetActiveAlarm().GetCurrentState())
}
if !received[2].GetSnapshotComplete() {
t.Fatalf("final message is not snapshot_complete")
}
}
func TestStreamAlarmsRejectsNilRequest(t *testing.T) {
func TestStreamAlarmsPassesFilterPrefix(t *testing.T) {
fake := &fakeGatewayWithAlarms{}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
if _, err := client.StreamAlarms(context.Background(), nil); err == nil {
t.Fatal("StreamAlarms(nil) returned no error")
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{
AlarmFilterPrefix: "Tank01.",
})
if err != nil {
t.Fatalf("StreamAlarms() error = %v", err)
}
for {
_, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("stream.Recv() error = %v", err)
}
}
if got := fake.streamRequest.GetAlarmFilterPrefix(); got != "Tank01." {
t.Fatalf("captured filter prefix = %q", got)
}
}
@@ -236,12 +171,8 @@ type fakeGatewayWithAlarms struct {
acknowledgeError error
acknowledgeAuth string
queryRequest *pb.QueryActiveAlarmsRequest
streamRequest *pb.StreamAlarmsRequest
activeSnapshots []*pb.ActiveAlarmSnapshot
streamRequest *pb.StreamAlarmsRequest
feedMessages []*pb.AlarmFeedMessage
streamAuth string
}
func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.AcknowledgeAlarmRequest) (*pb.AcknowledgeAlarmReply, error) {
@@ -254,32 +185,24 @@ func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.Ac
return s.acknowledgeReply, nil
}
return &pb.AcknowledgeAlarmReply{
CorrelationId: req.GetClientCorrelationId(),
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
}, nil
}
func (s *fakeGatewayWithAlarms) QueryActiveAlarms(req *pb.QueryActiveAlarmsRequest, stream grpc.ServerStreamingServer[pb.ActiveAlarmSnapshot]) error {
s.queryRequest = req
for _, snap := range s.activeSnapshots {
if err := stream.Send(snap); err != nil {
return err
}
}
return nil
}
func (s *fakeGatewayWithAlarms) StreamAlarms(req *pb.StreamAlarmsRequest, stream grpc.ServerStreamingServer[pb.AlarmFeedMessage]) error {
s.streamRequest = req
s.streamAuth = authorizationFromContext(stream.Context())
for _, msg := range s.feedMessages {
if err := stream.Send(msg); err != nil {
for _, snap := range s.activeSnapshots {
if err := stream.Send(&pb.AlarmFeedMessage{
Payload: &pb.AlarmFeedMessage_ActiveAlarm{ActiveAlarm: snap},
}); err != nil {
return err
}
}
return nil
return stream.Send(&pb.AlarmFeedMessage{
Payload: &pb.AlarmFeedMessage_SnapshotComplete{SnapshotComplete: true},
})
}
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
@@ -293,8 +216,10 @@ func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Cli
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
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{
Endpoint: "bufnet",
Endpoint: "passthrough:///bufnet",
APIKey: "test-api-key",
Plaintext: true,
DialOptions: []grpc.DialOption{grpc.WithContextDialer(dialer)},
+72 -31
View File
@@ -19,6 +19,7 @@ import (
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/durationpb"
@@ -36,22 +37,36 @@ type Client struct {
opts Options
}
// Dial opens a gRPC connection to the gateway and configures auth metadata,
// transport security, and blocking dial cancellation from ctx.
// Dial opens a gRPC connection to the gateway and configures auth metadata
// and transport security.
//
// 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) {
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 == "" {
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 {
return nil, err
@@ -61,16 +76,46 @@ func Dial(ctx context.Context, opts Options) (*Client, error) {
grpc.WithTransportCredentials(transportCredentials),
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
grpc.WithBlock(),
}
dialOptions = append(dialOptions, opts.DialOptions...)
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
conn, err := grpc.NewClient(opts.Endpoint, dialOptions...)
if err != nil {
return nil, &GatewayError{Op: "dial", Err: err}
}
return NewClient(conn, opts), nil
if err := waitForReady(ctx, conn, opts.DialTimeout); err != 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
@@ -188,7 +233,15 @@ func (c *Client) Close() error {
}
func (c *Client) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.opts.CallTimeout
return callContext(ctx, 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 {
timeout = defaultCallTimeout
}
@@ -222,22 +275,10 @@ func resolveTransportCredentials(opts Options) (credentials.TransportCredentials
return credentials.NewTLS(cfg), nil
}
return credentials.NewTLS(tlsConfigForOptions(opts)), nil
}
// tlsConfigForOptions returns the *tls.Config for the no-CA, no-custom-config TLS path.
// It returns nil when the caller should use a different credentials path (CA file or custom TLSConfig).
// Exposed as an internal helper so unit tests can assert the InsecureSkipVerify posture.
func tlsConfigForOptions(opts Options) *tls.Config {
// CA file and custom TLSConfig take their own paths in resolveTransportCredentials.
if opts.CACertFile != "" || opts.TLSConfig != nil {
return nil
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: opts.ServerNameOverride,
InsecureSkipVerify: !opts.RequireCertificateValidation, //nolint:gosec // internal tool; self-signed gateway cert expected; opt-in strict via RequireCertificateValidation
}
return credentials.NewTLS(&tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: opts.ServerNameOverride,
}), nil
}
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
+41 -144
View File
@@ -117,7 +117,7 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
streamDone: make(chan struct{}),
streamEventCount: 64,
streamEventCount: 256,
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
@@ -135,12 +135,25 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
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 {
select {
case _, ok := <-events:
case result, ok := <-events:
if !ok {
if !sawOverflow {
t.Fatal("compatibility event channel closed without an ErrEventBufferOverflow result")
}
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):
t.Fatal("compatibility event channel did not close")
}
@@ -241,8 +254,8 @@ func TestWriteBulkBuildsOneBulkCommandAndReturnsPerEntryResults(t *testing.T) {
Payload: &pb.MxCommandReply_WriteBulk{
WriteBulk: &pb.BulkWriteReply{
Results: []*pb.BulkWriteResult{
{ItemHandle: 10, WasSuccessful: true},
{ItemHandle: 11, WasSuccessful: true},
{ServerHandle: 12, ItemHandle: 901, WasSuccessful: true},
{ServerHandle: 12, ItemHandle: 902, WasSuccessful: false, ErrorMessage: "invalid handle"},
},
},
},
@@ -252,114 +265,22 @@ func TestWriteBulkBuildsOneBulkCommandAndReturnsPerEntryResults(t *testing.T) {
defer cleanup()
session := NewSessionForID(client, "session-1")
entries := []*WriteBulkEntry{
{ItemHandle: 10, Value: Int32Value(7), UserId: 100},
{ItemHandle: 11, Value: Int32Value(8), UserId: 100},
}
results, err := session.WriteBulk(context.Background(), 12, entries)
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 {
t.Fatalf("results len = %d, want 2", len(results))
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("entry count = %d, want 2", len(got))
}
}
func TestWriteBulkRejectsNilEntries(t *testing.T) {
fake := &fakeGatewayServer{}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
if _, err := session.WriteBulk(context.Background(), 12, nil); err == nil {
t.Fatal("WriteBulk(nil) returned no error")
}
if _, err := session.Write2Bulk(context.Background(), 12, nil); err == nil {
t.Fatal("Write2Bulk(nil) returned no error")
}
if _, err := session.WriteSecuredBulk(context.Background(), 12, nil); err == nil {
t.Fatal("WriteSecuredBulk(nil) returned no error")
}
if _, err := session.WriteSecured2Bulk(context.Background(), 12, nil); err == nil {
t.Fatal("WriteSecured2Bulk(nil) returned no error")
}
if _, err := session.ReadBulk(context.Background(), 12, nil, 0); err == nil {
t.Fatal("ReadBulk(nil) returned no error")
}
}
func TestBulkMethodsShortCircuitOnEmptySliceWithoutRoundTrip(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
results, err := session.WriteBulk(context.Background(), 12, []*WriteBulkEntry{})
if err != nil {
t.Fatalf("WriteBulk(empty) error = %v", err)
}
if len(results) != 0 {
t.Fatalf("WriteBulk(empty) results len = %d, want 0", len(results))
}
if fake.invokeRequest != nil {
t.Fatal("WriteBulk(empty) sent a round trip; expected short-circuit")
}
results2, err := session.Write2Bulk(context.Background(), 12, []*Write2BulkEntry{})
if err != nil {
t.Fatalf("Write2Bulk(empty) error = %v", err)
}
if len(results2) != 0 {
t.Fatalf("Write2Bulk(empty) results len = %d, want 0", len(results2))
}
if fake.invokeRequest != nil {
t.Fatal("Write2Bulk(empty) sent a round trip; expected short-circuit")
}
results3, err := session.WriteSecuredBulk(context.Background(), 12, []*WriteSecuredBulkEntry{})
if err != nil {
t.Fatalf("WriteSecuredBulk(empty) error = %v", err)
}
if len(results3) != 0 {
t.Fatalf("WriteSecuredBulk(empty) results len = %d, want 0", len(results3))
}
if fake.invokeRequest != nil {
t.Fatal("WriteSecuredBulk(empty) sent a round trip; expected short-circuit")
}
results4, err := session.WriteSecured2Bulk(context.Background(), 12, []*WriteSecured2BulkEntry{})
if err != nil {
t.Fatalf("WriteSecured2Bulk(empty) error = %v", err)
}
if len(results4) != 0 {
t.Fatalf("WriteSecured2Bulk(empty) results len = %d, want 0", len(results4))
}
if fake.invokeRequest != nil {
t.Fatal("WriteSecured2Bulk(empty) sent a round trip; expected short-circuit")
}
readResults, err := session.ReadBulk(context.Background(), 12, []string{}, 0)
if err != nil {
t.Fatalf("ReadBulk(empty) error = %v", err)
}
if len(readResults) != 0 {
t.Fatalf("ReadBulk(empty) results len = %d, want 0", len(readResults))
}
if fake.invokeRequest != nil {
t.Fatal("ReadBulk(empty) sent a round trip; expected short-circuit")
t.Fatalf("entries = %#v, want 2", got)
}
}
@@ -374,8 +295,14 @@ func TestReadBulkForwardsTimeoutAndUnpacksCachedFlag(t *testing.T) {
Payload: &pb.MxCommandReply_ReadBulk{
ReadBulk: &pb.BulkReadReply{
Results: []*pb.BulkReadResult{
{TagAddress: "Tank01.Level", WasSuccessful: true, WasCached: true},
{TagAddress: "Tank02.Level", WasSuccessful: true, WasCached: false},
{
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}},
},
},
},
},
@@ -385,48 +312,15 @@ func TestReadBulkForwardsTimeoutAndUnpacksCachedFlag(t *testing.T) {
defer cleanup()
session := NewSessionForID(client, "session-1")
results, err := session.ReadBulk(context.Background(), 12, []string{"Tank01.Level", "Tank02.Level"}, 250*time.Millisecond)
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) != 2 {
t.Fatalf("results len = %d, want 2", len(results))
if len(results) != 1 || !results[0].GetWasCached() || results[0].GetValue().GetInt32Value() != 99 {
t.Fatalf("results = %#v", results)
}
if !results[0].GetWasCached() || results[1].GetWasCached() {
t.Fatalf("WasCached flags = [%v %v], want [true false]", results[0].GetWasCached(), results[1].GetWasCached())
}
req := fake.invokeRequest
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK {
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
}
if got := req.GetCommand().GetReadBulk().GetTimeoutMs(); got != 250 {
t.Fatalf("timeout ms = %d, want 250", got)
}
}
func TestReadBulkSaturatesTimeoutAboveMaxUint32(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,
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
// 100 days in milliseconds exceeds MaxUint32 (~49.7 days).
hugeTimeout := 100 * 24 * time.Hour
_, err := session.ReadBulk(context.Background(), 12, []string{"Tank01.Level"}, hugeTimeout)
if err != nil {
t.Fatalf("ReadBulk() error = %v", err)
}
got := fake.invokeRequest.GetCommand().GetReadBulk().GetTimeoutMs()
if got != ^uint32(0) {
t.Fatalf("timeout ms = %d, want %d (MaxUint32)", got, ^uint32(0))
if got := fake.invokeRequest.GetCommand().GetReadBulk().GetTimeoutMs(); got != 750 {
t.Fatalf("timeout_ms = %d, want 750", got)
}
}
@@ -479,8 +373,11 @@ func newBufconnClient(t *testing.T, fake *fakeGatewayServer) (*Client, func()) {
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
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{
Endpoint: "bufnet",
Endpoint: "passthrough:///bufnet",
APIKey: "test-api-key",
Plaintext: true,
DialOptions: []grpc.DialOption{
-59
View File
@@ -1,59 +0,0 @@
package mxgateway
import (
"crypto/tls"
"testing"
)
// tlsConfigFromOptions is the internal helper under test.
// It extracts the *tls.Config from the no-CA TLS path of resolveTransportCredentials.
// We exercise it directly to avoid needing a real dial target.
func TestTLSInsecureSkipVerify_DefaultTrue(t *testing.T) {
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
})
if cfg == nil {
t.Fatal("expected non-nil tls.Config")
}
if !cfg.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be true by default when no CA is pinned")
}
}
func TestTLSInsecureSkipVerify_FalseWhenRequireCertificateValidation(t *testing.T) {
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
RequireCertificateValidation: true,
})
if cfg == nil {
t.Fatal("expected non-nil tls.Config")
}
if cfg.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be false when RequireCertificateValidation is true")
}
}
func TestTLSInsecureSkipVerify_FalseWhenCACertFileSet(t *testing.T) {
// When a CA file is pinned, the CA-verification path is taken instead.
// tlsConfigForOptions should return nil (the CA path does not use our helper).
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
CACertFile: "/some/ca.pem",
})
if cfg != nil {
t.Error("expected nil tls.Config when CACertFile is set (CA path taken)")
}
}
func TestTLSInsecureSkipVerify_FalseWhenCustomTLSConfig(t *testing.T) {
// When TLSConfig is supplied explicitly, our default skip-verify must not overwrite it.
custom := &tls.Config{MinVersion: tls.VersionTLS13}
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
TLSConfig: custom,
})
if cfg != nil {
t.Error("expected nil tls.Config when TLSConfig is already set (custom config path taken)")
}
}
+401
View File
@@ -0,0 +1,401 @@
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)
}
}
+55 -1
View File
@@ -1,11 +1,22 @@
package mxgateway
import (
"errors"
"fmt"
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.
type GatewayError struct {
// Op names the operation that failed (for example "dial" or "invoke").
@@ -33,6 +44,45 @@ func (e *GatewayError) Unwrap() error {
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
// command reply when one exists.
type CommandError struct {
@@ -85,8 +135,12 @@ func (e *MxAccessError) Error() string {
}
// 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 {
if e == nil {
if e == nil || e.Command == nil {
return nil
}
return e.Command
+42
View File
@@ -0,0 +1,42 @@
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)
}
}
+12 -284
View File
@@ -3,9 +3,7 @@ package mxgateway
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
@@ -15,14 +13,6 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)
// browseChildrenPageSize is the per-request page size used by the lazy walker.
const browseChildrenPageSize = 500
// discoverHierarchyPageSize is the per-request page size used by DiscoverHierarchy.
// Mirrors the .NET client constant so large galaxies are not silently truncated
// by the server's default page cap.
const discoverHierarchyPageSize = 5000
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
// Galaxy Repository service exposed for callers that need direct contract
// access.
@@ -50,10 +40,6 @@ type (
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
// DeployEvent is one Galaxy Repository deploy event.
DeployEvent = pb.DeployEvent
// BrowseChildrenRequest is the request for BrowseChildren.
BrowseChildrenRequest = pb.BrowseChildrenRequest
// BrowseChildrenReply is the reply for BrowseChildren.
BrowseChildrenReply = pb.BrowseChildrenReply
)
// RawDeployEventStream is the generated WatchDeployEvents client stream.
@@ -70,39 +56,13 @@ type GalaxyClient struct {
// DialGalaxy opens a gRPC connection to the gateway for the Galaxy Repository
// service. It applies the same authentication metadata, transport security,
// and dial-timeout behavior as Dial.
// lazy connection, and DialTimeout-bounded readiness probe as Dial.
func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) {
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)
conn, err := dial(ctx, opts)
if err != nil {
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
}
@@ -160,35 +120,16 @@ func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool,
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
// object's dynamic attributes. The objects are returned in the order supplied
// by the server. The call pages over the server's NextPageToken until the
// server signals it has no more results, matching the .NET client.
// by the server.
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
var objects []*GalaxyObject
pageToken := ""
seen := map[string]struct{}{}
for {
callCtx, cancel := c.callContext(ctx)
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{
PageSize: discoverHierarchyPageSize,
PageToken: pageToken,
})
cancel()
if err != nil {
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
}
objects = append(objects, reply.GetObjects()...)
pageToken = reply.GetNextPageToken()
if pageToken == "" {
return objects, nil
}
if _, dup := seen[pageToken]; dup {
return nil, &GatewayError{
Op: "galaxy discover hierarchy",
Err: fmt.Errorf("repeated page token %q", pageToken),
}
}
seen[pageToken] = struct{}{}
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{})
if err != nil {
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
}
return reply.GetObjects(), nil
}
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
@@ -246,7 +187,7 @@ func (c *GalaxyClient) WatchDeployEvents(
}
continue
}
if recvErr == io.EOF {
if errors.Is(recvErr, io.EOF) {
return
}
if status.Code(recvErr) == codes.Canceled || ctx.Err() != nil {
@@ -271,219 +212,6 @@ func (c *GalaxyClient) Close() error {
return c.conn.Close()
}
// LazyBrowseNode is one node in a lazy Galaxy hierarchy walk produced by
// (*GalaxyClient).Browse. Children are not fetched until Expand is called.
// The node is safe for concurrent use; concurrent Expand calls coalesce onto
// a single in-flight RPC and do not block snapshot accessors.
type LazyBrowseNode struct {
client *GalaxyClient
object *pb.GalaxyObject
hasChildrenHint bool
options BrowseChildrenOptions
// expandLock gates inspection and mutation of expand-coordination state
// (expanding, expandDone, expandErr). It is held only briefly; the BrowseChildren
// RPC itself runs outside this lock so concurrent readers and waiters are not blocked.
expandLock sync.Mutex
expanding bool
expandDone chan struct{}
expandErr error
// mu protects the children snapshot and isExpanded flag for concurrent
// Children() / IsExpanded() readers.
mu sync.RWMutex
children []*LazyBrowseNode
isExpanded bool
}
// Object returns the underlying GalaxyObject describing this node.
func (n *LazyBrowseNode) Object() *pb.GalaxyObject { return n.object }
// HasChildrenHint reports the server-supplied hint on whether this node has
// matching descendants under the current filter set.
func (n *LazyBrowseNode) HasChildrenHint() bool { return n.hasChildrenHint }
// Children returns a snapshot copy of the currently-loaded child nodes. Returns
// an empty slice when Expand has not yet been called.
func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
n.mu.RLock()
defer n.mu.RUnlock()
out := make([]*LazyBrowseNode, len(n.children))
copy(out, n.children)
return out
}
// IsExpanded reports whether Expand has completed successfully on this node.
func (n *LazyBrowseNode) IsExpanded() bool {
n.mu.RLock()
defer n.mu.RUnlock()
return n.isExpanded
}
// Expand fetches this node's direct children via BrowseChildren when they have
// not yet been loaded. Subsequent calls after a successful Expand are a no-op
// and do not issue another RPC.
//
// Expand is safe to call concurrently from multiple goroutines: callers that
// arrive while an expansion is in flight wait on the active RPC and share its
// result instead of issuing a second RPC. The RPC itself runs without holding
// the snapshot mutex, so concurrent Children() and IsExpanded() callers are
// not blocked for the duration of the network round trip.
//
// Failure semantics: a failed expansion surfaces the same error to every
// in-flight waiter, but the node is left in its pre-call state (isExpanded =
// false, no in-flight expansion). The next Expand call therefore retries with
// a fresh RPC; failures are not sticky.
func (n *LazyBrowseNode) Expand(ctx context.Context) error {
// Fast path: already expanded.
n.mu.RLock()
if n.isExpanded {
n.mu.RUnlock()
return nil
}
n.mu.RUnlock()
// Either start a new expansion or wait on an existing one.
n.expandLock.Lock()
n.mu.RLock()
alreadyExpanded := n.isExpanded
n.mu.RUnlock()
if alreadyExpanded {
n.expandLock.Unlock()
return nil
}
if n.expanding {
done := n.expandDone
n.expandLock.Unlock()
select {
case <-done:
n.expandLock.Lock()
err := n.expandErr
n.expandLock.Unlock()
return err
case <-ctx.Done():
return ctx.Err()
}
}
n.expanding = true
n.expandDone = make(chan struct{})
done := n.expandDone
n.expandLock.Unlock()
// Issue the RPC outside any lock so concurrent readers/waiters are not blocked.
parentID := n.object.GetGobjectId()
children, err := n.client.browseChildrenInner(ctx, &parentID, n.options)
if err == nil {
n.mu.Lock()
n.children = children
n.isExpanded = true
n.mu.Unlock()
}
// Publish result to waiters and clear the in-flight marker so a failed
// expansion can be retried by the next Expand call.
n.expandLock.Lock()
n.expandErr = err
n.expanding = false
close(done)
n.expandLock.Unlock()
return err
}
// Browse returns the root nodes of the Galaxy hierarchy. The returned nodes
// have only their server-supplied hints populated; call Expand on each node to
// fetch its direct children. When opts is nil the server defaults apply.
func (c *GalaxyClient) Browse(ctx context.Context, opts *BrowseChildrenOptions) ([]*LazyBrowseNode, error) {
effective := BrowseChildrenOptions{}
if opts != nil {
effective = *opts
}
return c.browseChildrenInner(ctx, nil, effective)
}
// BrowseChildrenRaw issues a single BrowseChildren RPC and returns the raw
// reply for callers that need direct page-token control. Transport-level
// failures are wrapped in *GatewayError to match the rest of the client.
func (c *GalaxyClient) BrowseChildrenRaw(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.BrowseChildren(callCtx, req)
if err != nil {
return nil, &GatewayError{Op: "galaxy browse children", Err: err}
}
return reply, nil
}
func (c *GalaxyClient) browseChildrenInner(
ctx context.Context,
parentGobjectID *int32,
opts BrowseChildrenOptions,
) ([]*LazyBrowseNode, error) {
var nodes []*LazyBrowseNode
pageToken := ""
seen := map[string]struct{}{}
for {
req := &pb.BrowseChildrenRequest{
PageSize: browseChildrenPageSize,
PageToken: pageToken,
CategoryIds: opts.CategoryIds,
TemplateChainContains: opts.TemplateChainContains,
TagNameGlob: opts.TagNameGlob,
AlarmBearingOnly: opts.AlarmBearingOnly,
HistorizedOnly: opts.HistorizedOnly,
}
if parentGobjectID != nil {
req.Parent = &pb.BrowseChildrenRequest_ParentGobjectId{ParentGobjectId: *parentGobjectID}
}
if opts.IncludeAttributes != nil {
req.IncludeAttributes = opts.IncludeAttributes
}
reply, err := c.BrowseChildrenRaw(ctx, req)
if err != nil {
return nil, err
}
for i, child := range reply.GetChildren() {
hasChildren := reply.GetChildHasChildren()
hint := i < len(hasChildren) && hasChildren[i]
nodes = append(nodes, &LazyBrowseNode{
client: c,
object: child,
hasChildrenHint: hint,
options: opts,
})
}
pageToken = reply.GetNextPageToken()
if pageToken == "" {
return nodes, nil
}
if _, dup := seen[pageToken]; dup {
return nil, &GatewayError{
Op: "galaxy browse children",
Err: fmt.Errorf("repeated page token %q", pageToken),
}
}
seen[pageToken] = struct{}{}
}
}
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
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)
return callContext(ctx, c.opts.CallTimeout)
}
+11 -454
View File
@@ -4,14 +4,11 @@ import (
"context"
"errors"
"net"
"sync"
"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/status"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -147,47 +144,6 @@ func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
}
}
func TestGalaxyDiscoverHierarchyPaginatesAcrossMultiplePages(t *testing.T) {
page1 := &pb.DiscoverHierarchyReply{
Objects: []*pb.GalaxyObject{
{GobjectId: 1, TagName: "A"},
{GobjectId: 2, TagName: "B"},
},
NextPageToken: "page-2",
TotalObjectCount: 3,
}
page2 := &pb.DiscoverHierarchyReply{
Objects: []*pb.GalaxyObject{
{GobjectId: 3, TagName: "C"},
},
TotalObjectCount: 3,
}
fake := &fakeGalaxyServer{
discoverHierarchyReplies: []*pb.DiscoverHierarchyReply{page1, page2},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
objs, err := client.DiscoverHierarchy(context.Background())
if err != nil {
t.Fatalf("DiscoverHierarchy: %v", err)
}
if got, want := len(objs), 3; got != want {
t.Fatalf("len(objs) = %d, want %d", got, want)
}
if len(fake.discoverHierarchyCalls) != 2 {
t.Fatalf("expected 2 RPC calls, got %d", len(fake.discoverHierarchyCalls))
}
if fake.discoverHierarchyCalls[0].GetPageSize() != discoverHierarchyPageSize {
t.Fatalf("first call PageSize = %d, want %d",
fake.discoverHierarchyCalls[0].GetPageSize(), discoverHierarchyPageSize)
}
if fake.discoverHierarchyCalls[1].GetPageToken() != "page-2" {
t.Fatalf("second call page token = %q, want %q",
fake.discoverHierarchyCalls[1].GetPageToken(), "page-2")
}
}
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
fake := &fakeGalaxyServer{failTest: true}
client, cleanup := newGalaxyBufconnClient(t, fake)
@@ -392,8 +348,10 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
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{
Endpoint: "bufnet",
Endpoint: "passthrough:///bufnet",
APIKey: "test-api-key",
Plaintext: true,
DialOptions: []grpc.DialOption{
@@ -414,20 +372,14 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
type fakeGalaxyServer struct {
pb.UnimplementedGalaxyRepositoryServer
testReply *pb.TestConnectionReply
testAuth string
failTest bool
deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply
discoverHierarchyCalls []*pb.DiscoverHierarchyRequest
discoverHierarchyReplies []*pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest
watchSendInterval time.Duration
watchHoldOpen bool
browseChildrenCalls []*pb.BrowseChildrenRequest
browseChildrenReplies []*pb.BrowseChildrenReply
browseChildrenError error
testReply *pb.TestConnectionReply
testAuth string
failTest bool
deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest
watchHoldOpen bool
}
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
@@ -449,12 +401,6 @@ func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLas
}
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
s.discoverHierarchyCalls = append(s.discoverHierarchyCalls, req)
if len(s.discoverHierarchyReplies) > 0 {
reply := s.discoverHierarchyReplies[0]
s.discoverHierarchyReplies = s.discoverHierarchyReplies[1:]
return reply, nil
}
if s.discoverReply != nil {
return s.discoverReply, nil
}
@@ -467,398 +413,9 @@ func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, s
if err := stream.Send(event); err != nil {
return err
}
if s.watchSendInterval > 0 {
select {
case <-time.After(s.watchSendInterval):
case <-stream.Context().Done():
return stream.Context().Err()
}
}
}
if s.watchHoldOpen {
<-stream.Context().Done()
}
return nil
}
func (s *fakeGalaxyServer) BrowseChildren(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
s.browseChildrenCalls = append(s.browseChildrenCalls, req)
if s.browseChildrenError != nil {
err := s.browseChildrenError
s.browseChildrenError = nil
return nil, err
}
if len(s.browseChildrenReplies) == 0 {
return &pb.BrowseChildrenReply{}, nil
}
reply := s.browseChildrenReplies[0]
s.browseChildrenReplies = s.browseChildrenReplies[1:]
return reply, nil
}
func obj(id int32, tag string, isArea bool) *pb.GalaxyObject {
return &pb.GalaxyObject{
GobjectId: id,
TagName: tag,
BrowseName: tag,
IsArea: isArea,
}
}
func buildBrowseReply(children []*pb.GalaxyObject, hasChildren []bool, seq uint64) *pb.BrowseChildrenReply {
return &pb.BrowseChildrenReply{
TotalChildCount: int32(len(children)),
CacheSequence: seq,
Children: children,
ChildHasChildren: hasChildren,
}
}
func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true), obj(99, "Other", false)},
[]bool{true, false},
7,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if got, want := len(roots), 2; got != want {
t.Fatalf("len(roots) = %d, want %d", got, want)
}
if roots[0].Object().GetTagName() != "Plant" {
t.Fatalf("roots[0].TagName = %q", roots[0].Object().GetTagName())
}
if !roots[0].HasChildrenHint() {
t.Fatal("roots[0].HasChildrenHint = false, want true")
}
if roots[0].IsExpanded() {
t.Fatal("roots[0].IsExpanded = true, want false")
}
if roots[1].HasChildrenHint() {
t.Fatal("roots[1].HasChildrenHint = true, want false")
}
if len(fake.browseChildrenCalls) != 1 {
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
}
if fake.browseChildrenCalls[0].GetParent() != nil {
t.Fatalf("root browse should not set Parent oneof, got %T", fake.browseChildrenCalls[0].GetParent())
}
}
func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Area1", true), obj(11, "Tank1", false)},
[]bool{true, false},
1,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
plant := roots[0]
if plant.IsExpanded() {
t.Fatal("plant.IsExpanded = true before Expand, want false")
}
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand: %v", err)
}
if !plant.IsExpanded() {
t.Fatal("plant.IsExpanded = false after Expand, want true")
}
children := plant.Children()
if len(children) != 2 {
t.Fatalf("len(children) = %d, want 2", len(children))
}
if children[0].Object().GetTagName() != "Area1" {
t.Fatalf("children[0].TagName = %q, want Area1", children[0].Object().GetTagName())
}
if !children[0].HasChildrenHint() {
t.Fatal("children[0].HasChildrenHint = false, want true")
}
if children[1].HasChildrenHint() {
t.Fatal("children[1].HasChildrenHint = true, want false")
}
if len(fake.browseChildrenCalls) != 2 {
t.Fatalf("BrowseChildren calls = %d, want 2", len(fake.browseChildrenCalls))
}
parent := fake.browseChildrenCalls[1].GetParent()
parentGobj, ok := parent.(*pb.BrowseChildrenRequest_ParentGobjectId)
if !ok {
t.Fatalf("Parent oneof = %T, want *BrowseChildrenRequest_ParentGobjectId", parent)
}
if parentGobj.ParentGobjectId != 1 {
t.Fatalf("ParentGobjectId = %d, want 1", parentGobj.ParentGobjectId)
}
}
func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Area1", true)},
[]bool{false},
1,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
plant := roots[0]
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand #1: %v", err)
}
callsAfterFirst := len(fake.browseChildrenCalls)
if callsAfterFirst != 2 {
t.Fatalf("BrowseChildren calls after first Expand = %d, want 2", callsAfterFirst)
}
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand #2: %v", err)
}
if got := len(fake.browseChildrenCalls); got != callsAfterFirst {
t.Fatalf("BrowseChildren calls after second Expand = %d, want %d (no extra RPC)", got, callsAfterFirst)
}
}
func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
},
browseChildrenError: status.Error(codes.NotFound, "parent not found"),
}
// The first Browse() consumes the first reply; the next call (Expand) will
// then hit browseChildrenError. We need the error to fire only on the second
// call, so seed the reply first and let the call sequence consume them in
// order. Because BrowseChildren in the fake consumes browseChildrenError
// before falling through to replies, swap the strategy: keep the root reply
// but have BrowseChildren return the error on the second call. We do this by
// emptying the reply list after the first Browse.
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
// First call returns the error (because browseChildrenError takes precedence).
// To avoid that, clear it for the root call by performing a manual setup: we
// pre-stage replies first, then set the error after the first call. Easiest:
// pre-Browse() with error=nil, then set error before Expand.
fake.browseChildrenError = nil
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
fake.browseChildrenError = status.Error(codes.NotFound, "parent not found")
err = roots[0].Expand(context.Background())
if err == nil {
t.Fatal("Expand: error = nil, want NotFound")
}
if status.Code(err) != codes.NotFound {
t.Fatalf("status.Code = %s, want NotFound", status.Code(err))
}
if roots[0].IsExpanded() {
t.Fatal("roots[0].IsExpanded = true after failed Expand, want false")
}
}
func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) {
firstPage := buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
7,
)
pageA := buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Child1", false), obj(11, "Child2", false)},
[]bool{false, false},
7,
)
pageA.NextPageToken = "7:abc:2"
pageB := buildBrowseReply(
[]*pb.GalaxyObject{obj(12, "Child3", false)},
[]bool{false},
7,
)
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{firstPage, pageA, pageB},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if err := roots[0].Expand(context.Background()); err != nil {
t.Fatalf("Expand: %v", err)
}
children := roots[0].Children()
if len(children) != 3 {
t.Fatalf("len(children) = %d, want 3", len(children))
}
if len(fake.browseChildrenCalls) != 3 {
t.Fatalf("BrowseChildren calls = %d, want 3", len(fake.browseChildrenCalls))
}
if got := fake.browseChildrenCalls[2].GetPageToken(); got != "7:abc:2" {
t.Fatalf("third call PageToken = %q, want %q", got, "7:abc:2")
}
}
func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(nil, nil, 1),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
include := true
opts := &BrowseChildrenOptions{
CategoryIds: []int32{7, 9},
TemplateChainContains: []string{"$AppObject"},
TagNameGlob: "Tank*",
IncludeAttributes: &include,
AlarmBearingOnly: true,
HistorizedOnly: true,
}
if _, err := client.Browse(context.Background(), opts); err != nil {
t.Fatalf("Browse: %v", err)
}
if len(fake.browseChildrenCalls) != 1 {
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
}
got := fake.browseChildrenCalls[0]
if want := []int32{7, 9}; len(got.GetCategoryIds()) != 2 || got.GetCategoryIds()[0] != want[0] || got.GetCategoryIds()[1] != want[1] {
t.Fatalf("CategoryIds = %v, want %v", got.GetCategoryIds(), want)
}
if want := []string{"$AppObject"}; len(got.GetTemplateChainContains()) != 1 || got.GetTemplateChainContains()[0] != want[0] {
t.Fatalf("TemplateChainContains = %v, want %v", got.GetTemplateChainContains(), want)
}
if got.GetTagNameGlob() != "Tank*" {
t.Fatalf("TagNameGlob = %q, want %q", got.GetTagNameGlob(), "Tank*")
}
if !got.GetIncludeAttributes() {
t.Fatal("IncludeAttributes = false, want true")
}
if !got.GetAlarmBearingOnly() {
t.Fatal("AlarmBearingOnly = false, want true")
}
if !got.GetHistorizedOnly() {
t.Fatal("HistorizedOnly = false, want true")
}
}
func TestGalaxyBrowseExpandConcurrentCallersOnlyFireOneRpc(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
// roots
buildBrowseReply([]*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 7),
// one expand: one child
buildBrowseReply([]*pb.GalaxyObject{obj(2, "Mixer", false)}, []bool{false}, 7),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx := context.Background()
roots, err := client.Browse(ctx, nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
var wg sync.WaitGroup
errs := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
errs <- roots[0].Expand(ctx)
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent Expand: %v", err)
}
}
if !roots[0].IsExpanded() {
t.Fatal("IsExpanded() = false after 10 concurrent expands")
}
if got, want := len(roots[0].Children()), 1; got != want {
t.Fatalf("len(children) = %d, want %d", got, want)
}
// 1 roots fetch + exactly 1 expand fetch.
if got, want := len(fake.browseChildrenCalls), 2; got != want {
t.Fatalf("RPC count = %d, want %d", got, want)
}
}
func TestGalaxyBrowseChildrenRejectsRepeatedPageToken(t *testing.T) {
// Build a reply that carries a non-empty NextPageToken so browseChildrenInner
// will request a second page. Queue the same reply twice so the second response
// returns the same page token, triggering the duplicate-token guard.
page := buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
)
page.NextPageToken = "1:abc:1"
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{page, page},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
_, err := client.Browse(context.Background(), nil)
if err == nil {
t.Fatal("Browse: error = nil, want repeated-page-token error")
}
var gwErr *GatewayError
if !errors.As(err, &gwErr) {
t.Fatalf("error type = %T, want *GatewayError; err = %v", err, err)
}
}
-26
View File
@@ -34,32 +34,6 @@ type Options struct {
TransportCredentials credentials.TransportCredentials
// DialOptions are appended to the gRPC dial options after the defaults.
DialOptions []grpc.DialOption
// RequireCertificateValidation forces TLS certificate verification even when
// no CACertFile is pinned. Default false: the gateway's self-signed cert is
// accepted without verification (internal-tool posture).
RequireCertificateValidation bool
}
// BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by
// (*GalaxyClient).Browse and (*LazyBrowseNode).Expand. All fields are optional;
// the zero value matches the dashboard default (no filters, all attributes per
// the server default).
type BrowseChildrenOptions struct {
// CategoryIds restricts results to the listed Galaxy category ids when set.
CategoryIds []int32
// TemplateChainContains restricts results to objects whose template chain
// contains any of the listed template tag names.
TemplateChainContains []string
// TagNameGlob restricts results to objects whose tag name matches the glob
// pattern when non-empty.
TagNameGlob string
// IncludeAttributes overrides the server default for attribute inclusion when
// non-nil. The pointer form mirrors the proto's optional field.
IncludeAttributes *bool
// AlarmBearingOnly limits results to alarm-bearing objects when true.
AlarmBearingOnly bool
// HistorizedOnly limits results to historized objects when true.
HistorizedOnly bool
}
// RedactedAPIKey returns a display-safe representation of the configured API
+44 -35
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"sync"
"sync/atomic"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
@@ -392,9 +393,6 @@ func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemH
// Per-entry failures appear as BulkWriteResult entries with WasSuccessful=false; the call
// never returns an error for per-entry MXAccess failures (it returns an error only for
// protocol-level failures or transport errors).
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*WriteBulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write bulk entries are required")
@@ -402,9 +400,6 @@ func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*
if err := ensureBulkSize("write bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
Payload: &pb.MxCommand_WriteBulk{
@@ -421,9 +416,6 @@ func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*
}
// Write2Bulk invokes MXAccess Write2 (timestamped) for each entry inside one gateway command.
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []*Write2BulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write2 bulk entries are required")
@@ -431,9 +423,6 @@ func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []
if err := ensureBulkSize("write2 bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK,
Payload: &pb.MxCommand_Write2Bulk{
@@ -451,9 +440,6 @@ func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []
// WriteSecuredBulk invokes MXAccess WriteSecured for each entry. Credential-sensitive
// values must not be logged by callers; mirrors the single-item WriteSecured contract.
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entries []*WriteSecuredBulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write-secured bulk entries are required")
@@ -461,9 +447,6 @@ func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entr
if err := ensureBulkSize("write-secured bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK,
Payload: &pb.MxCommand_WriteSecuredBulk{
@@ -480,9 +463,6 @@ func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entr
}
// WriteSecured2Bulk invokes MXAccess WriteSecured2 (timestamped) for each entry.
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, entries []*WriteSecured2BulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write-secured2 bulk entries are required")
@@ -490,9 +470,6 @@ func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, ent
if err := ensureBulkSize("write-secured2 bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK,
Payload: &pb.MxCommand_WriteSecured2Bulk{
@@ -516,10 +493,6 @@ func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, ent
// otherwise. timeout bounds the wait per tag in the snapshot case; pass zero to use the
// worker default. Per-tag failures (timeout, invalid tag) appear as BulkReadResult entries
// with WasSuccessful=false; the call never returns an error for per-tag MXAccess failures.
//
// A non-nil but empty tagAddresses slice is treated as a no-op and returns an empty
// result without a wire round-trip; pass nil to surface a clear "tag addresses are
// required" error.
func (s *Session) ReadBulk(ctx context.Context, serverHandle int32, tagAddresses []string, timeout time.Duration) ([]*BulkReadResult, error) {
if tagAddresses == nil {
return nil, errors.New("mxgateway: tag addresses are required")
@@ -527,9 +500,6 @@ func (s *Session) ReadBulk(ctx context.Context, serverHandle int32, tagAddresses
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
return nil, err
}
if len(tagAddresses) == 0 {
return []*BulkReadResult{}, nil
}
var timeoutMs uint32
if timeout > 0 {
ms := timeout.Milliseconds()
@@ -629,7 +599,7 @@ func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence
}
continue
}
if err == io.EOF || status.Code(err) == codes.Canceled || streamCtx.Err() != nil {
if errors.Is(err, io.EOF) || status.Code(err) == codes.Canceled || streamCtx.Err() != nil {
return
}
sendEventResult(
@@ -658,7 +628,7 @@ func ensureBulkSize(name string, length int) error {
func sendEventResult(
ctx context.Context,
results chan<- EventResult,
results chan EventResult,
result EventResult,
cancelWhenBufferFull bool,
cancel context.CancelFunc,
@@ -670,7 +640,12 @@ func sendEventResult(
case <-ctx.Done():
return false
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()
deliverTerminalResult(results, EventResult{Err: ErrEventBufferOverflow})
return false
}
}
@@ -683,6 +658,25 @@ 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) {
return s.client.Invoke(ctx, &pb.MxCommandRequest{
SessionId: s.ID(),
@@ -691,10 +685,25 @@ 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 {
var buffer [16]byte
if _, err := rand.Read(buffer[:]); err != nil {
return ""
if _, err := randRead(buffer[:]); err != nil {
return fmt.Sprintf("fallback-%x-%x",
time.Now().UnixNano(), correlationIDCounter.Add(1))
}
return hex.EncodeToString(buffer[:])
}
+26 -22
View File
@@ -70,32 +70,32 @@ type (
WriteCommand = pb.WriteCommand
// Write2Command is the payload of an MXAccess Write2 command.
Write2Command = pb.Write2Command
// WriteBulkCommand is the payload of a bulk Write command.
// WriteBulkCommand carries one bulk-Write request.
WriteBulkCommand = pb.WriteBulkCommand
// WriteBulkEntry is one entry inside a WriteBulkCommand.
// WriteBulkEntry is one (item_handle, value, user_id) tuple in a WriteBulk request.
WriteBulkEntry = pb.WriteBulkEntry
// Write2BulkCommand is the payload of a bulk Write2 (timestamped) command.
// Write2BulkCommand carries one bulk-Write2 (timestamped) request.
Write2BulkCommand = pb.Write2BulkCommand
// Write2BulkEntry is one entry inside a Write2BulkCommand.
// Write2BulkEntry is one (item_handle, value, timestamp_value, user_id) tuple in a Write2Bulk request.
Write2BulkEntry = pb.Write2BulkEntry
// WriteSecuredBulkCommand is the payload of a bulk WriteSecured command.
// WriteSecuredBulkCommand carries one bulk-WriteSecured request. Values are credential-sensitive.
WriteSecuredBulkCommand = pb.WriteSecuredBulkCommand
// WriteSecuredBulkEntry is one entry inside a WriteSecuredBulkCommand.
// WriteSecuredBulkEntry is one entry in a WriteSecuredBulk request.
WriteSecuredBulkEntry = pb.WriteSecuredBulkEntry
// WriteSecured2BulkCommand is the payload of a bulk WriteSecured2 (timestamped) command.
// WriteSecured2BulkCommand carries one bulk-WriteSecured2 (timestamped) request.
WriteSecured2BulkCommand = pb.WriteSecured2BulkCommand
// WriteSecured2BulkEntry is one entry inside a WriteSecured2BulkCommand.
// WriteSecured2BulkEntry is one entry in a WriteSecured2Bulk request.
WriteSecured2BulkEntry = pb.WriteSecured2BulkEntry
// ReadBulkCommand is the payload of a bulk Read snapshot command.
// ReadBulkCommand carries one bulk-Read request.
ReadBulkCommand = pb.ReadBulkCommand
// BulkWriteReply aggregates BulkWriteResult entries for a bulk write command.
BulkWriteReply = pb.BulkWriteReply
// BulkWriteResult is one entry in a bulk write reply list.
// BulkWriteResult is one per-entry result in a bulk-write reply.
BulkWriteResult = pb.BulkWriteResult
// BulkReadReply aggregates BulkReadResult entries for a bulk read command.
BulkReadReply = pb.BulkReadReply
// BulkReadResult is one entry in a bulk read reply list.
// BulkWriteReply aggregates BulkWriteResult entries for a bulk-write command.
BulkWriteReply = pb.BulkWriteReply
// BulkReadResult is one per-tag result in a bulk-read reply (carries the snapshot value plus a was_cached flag).
BulkReadResult = pb.BulkReadResult
// BulkReadReply aggregates BulkReadResult entries for a ReadBulk command.
BulkReadReply = pb.BulkReadReply
// RegisterReply carries the ServerHandle returned by Register.
RegisterReply = pb.RegisterReply
// AddItemReply carries the ItemHandle returned by AddItem.
@@ -110,14 +110,12 @@ type (
AcknowledgeAlarmRequest = pb.AcknowledgeAlarmRequest
// AcknowledgeAlarmReply is the gateway AcknowledgeAlarm reply message.
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
// QueryActiveAlarmsRequest is the gateway QueryActiveAlarms request message.
QueryActiveAlarmsRequest = pb.QueryActiveAlarmsRequest
// StreamAlarmsRequest is the gateway StreamAlarms request message.
StreamAlarmsRequest = pb.StreamAlarmsRequest
// AlarmFeedMessage is one message on the StreamAlarms feed — an
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
AlarmFeedMessage = pb.AlarmFeedMessage
// ActiveAlarmSnapshot is one row in a ConditionRefresh stream.
// ActiveAlarmSnapshot is one currently-active alarm in the feed snapshot.
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
OnAlarmTransitionEvent = pb.OnAlarmTransitionEvent
@@ -131,10 +129,6 @@ type AlarmTransitionKind = pb.AlarmTransitionKind
// ConditionRefresh snapshot.
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
// StreamAlarms RPC.
type StreamAlarmsClient = pb.MxAccessGateway_StreamAlarmsClient
@@ -190,6 +184,16 @@ const (
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
// CommandKindWrite2 selects the MXAccess Write2 command.
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 = pb.MxDataType_MX_DATA_TYPE_UNKNOWN
+11 -28
View File
@@ -18,13 +18,13 @@ clients/java/
settings.gradle
build.gradle
src/main/generated/
zb-mom-ww-mxgateway-client/
mxgateway-client/
build.gradle
src/main/java/com/zb/mom/ww/mxgateway/client/
src/test/java/com/zb/mom/ww/mxgateway/client/
zb-mom-ww-mxgateway-cli/
src/main/java/com/dohertylan/mxgateway/client/
src/test/java/com/dohertylan/mxgateway/client/
mxgateway-cli/
build.gradle
src/main/java/com/zb/mom/ww/mxgateway/cli/
src/main/java/com/dohertylan/mxgateway/cli/
```
Alternative Maven layout is acceptable if the repo standardizes on Maven.
@@ -112,23 +112,6 @@ Support:
- custom CA certificate file,
- server name override for test environments.
### Trust posture
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). To make that usable, TLS is **lenient by default**: when the channel is not
plaintext and no `caCertificatePath` is set, the client builds
`GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)`
(grpc-netty-shaded), so the gateway's self-signed certificate is accepted without
verification.
To verify the gateway instead:
- set `caCertificatePath` to pin a CA (full verification against that root), or
- set `requireCertificateValidation` to `true` to verify against the JVM trust
store without pinning.
Pinning a CA always wins over the lenient default.
## Streaming
Support both:
@@ -209,8 +192,8 @@ stream for bounded time, and close.
Publish library and CLI separately:
- `zb-mom-ww-mxgateway-client` jar,
- `zb-mom-ww-mxgateway-cli` runnable distribution.
- `mxgateway-client` jar,
- `mxgateway-cli` runnable distribution.
Generated protobuf code should be produced during the build from shared proto
files and should not be hand-edited.
@@ -223,10 +206,10 @@ Run the Java scaffold checks from `clients/java`:
gradle test
```
The `zb-mom-ww-mxgateway-client` project generates the gateway and worker
protobuf/gRPC bindings into `src/main/generated`, compiles the generated
contracts, and runs JUnit 5 tests. The `zb-mom-ww-mxgateway-cli` project
builds a Picocli-based `mxgw-java` entry point for later command implementation.
The `mxgateway-client` project generates the gateway and worker protobuf/gRPC
bindings into `src/main/generated`, compiles the generated contracts, and runs
JUnit 5 tests. The `mxgateway-cli` project builds a Picocli-based `mxgw-java`
entry point for later command implementation.
## Related Documentation
+77 -130
View File
@@ -10,23 +10,22 @@ clients/java/
settings.gradle
build.gradle
src/main/generated/
zb-mom-ww-mxgateway-client/
zb-mom-ww-mxgateway-cli/
mxgateway-client/
mxgateway-cli/
```
`zb-mom-ww-mxgateway-client` generates Java protobuf and gRPC sources from
`../../src/ZB.MOM.WW.MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
`mxgateway-client` generates Java protobuf and gRPC sources from
`../../src/MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
generated sources under `src/main/generated`, which matches the client proto
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
`zb-mom-ww-mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
`mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
generated stubs, and generated protobuf messages for parity tests.
`zb-mom-ww-mxgateway-cli` depends on `zb-mom-ww-mxgateway-client` and provides
the `mxgw-java` application entry point. The CLI supports version, session,
command, event streaming, write, and smoke-test commands with deterministic
JSON output.
`mxgateway-cli` depends on `mxgateway-client` and provides the `mxgw-java`
application entry point. The CLI supports version, session, command, event
streaming, write, and smoke-test commands with deterministic JSON output.
## Regenerating Protobuf Bindings
@@ -34,7 +33,7 @@ Run generation from `clients/java` after the shared `.proto` files or Java
output path changes:
```powershell
gradle :zb-mom-ww-mxgateway-client:generateProto
gradle :mxgateway-client:generateProto
```
## Client Usage
@@ -57,32 +56,66 @@ try (MxGatewayClient client = MxGatewayClient.connect(options);
}
```
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
the client is **lenient by default**: a TLS connection (`plaintext(false)`) with
no `caCertificatePath` accepts whatever certificate the gateway presents (via
grpc-netty-shaded's `InsecureTrustManagerFactory`). To verify instead, set
`caCertificatePath` to pin a CA, or set `requireCertificateValidation(true)` to
verify against the JVM trust store without pinning. Use `serverNameOverride` /
`--server-name-override` when the dialed host differs from the certificate SAN.
See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
underlying protobuf messages. `MxGatewayCommandException` and
`MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a
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
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
call on the worker STA.
call on the worker STA. Closing an `MxEventStream` *before* the gRPC call has
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.
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
yields alarm-feed messages from the gateway's central monitor), and
`acknowledgeAlarm` (ack by full alarm reference with an optional comment and
ack target). Close the subscription to cancel the underlying gRPC stream.
Cancellation of `CompletableFuture` results from `openSessionAsync`,
`invokeAsync`, `acknowledgeAlarmAsync`, `getLastDeployTimeAsync`,
`testConnectionAsync`, and `discoverHierarchyAsync` forwards to the underlying
gRPC call: calling `cancel(true)` on the returned future aborts the in-flight
RPC instead of merely detaching the future from its result.
## Galaxy Repository Browse
@@ -121,64 +154,11 @@ The CLI exposes matching subcommands: `galaxy-test`, `galaxy-deploy-time`,
`--timeout`, and `--json` options as the gateway commands.
```powershell
gradle :zb-mom-ww-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-deploy-time --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"
gradle :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 :mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
```
### Browsing lazily
For UI trees or OPC UA bridges, use `browseChildren` to walk one level at a
time instead of loading the full hierarchy with `discoverHierarchy`. Pass a
default request for root objects; subsequent calls set `parentGobjectId`,
`parentTagName`, or `parentContainedPath`. Filter fields match
`DiscoverHierarchy`. Each response pairs `getChildrenList()` with
`getChildHasChildrenList()` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics. This snippet documents the API as it appears once
the Java client is regenerated on the Windows host.
```java
BrowseChildrenReply reply = galaxy.browseChildren(
BrowseChildrenRequest.newBuilder().build());
List<GalaxyObject> children = reply.getChildrenList();
List<Boolean> hasChildren = reply.getChildHasChildrenList();
for (int i = 0; i < children.size(); i++) {
System.out.printf("%s expand=%b%n", children.get(i).getTagName(), hasChildren.get(i));
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```java
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
List<LazyBrowseNode> roots = galaxy.browse();
for (LazyBrowseNode root : roots) {
if (root.hasChildrenHint()) {
root.expand();
}
for (LazyBrowseNode child : root.getChildren()) {
String kind = child.hasChildrenHint() ? "has children" : "leaf";
System.out.println(child.getObject().getTagName() + " (" + kind + ")");
}
}
}
```
`expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`browse` again from the root.
### Watching deploy events
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
@@ -226,8 +206,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`:
```powershell
gradle :zb-mom-ww-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 --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
gradle :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"
```
## CLI Usage
@@ -235,16 +215,14 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:50
Run the CLI through Gradle:
```powershell
gradle :zb-mom-ww-mxgateway-cli:run --args="version --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 :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 :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 :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 :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 :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
gradle :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 :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 :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 :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"
```
The CLI accepts `--api-key`, `--api-key-env`, `--plaintext`, `--ca-file`,
@@ -254,7 +232,7 @@ output redacts API keys.
Use TLS options for a secured gateway:
```powershell
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"
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"
```
## Build And Test
@@ -274,11 +252,11 @@ in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
Create local library and CLI artifacts from `clients/java`:
```powershell
gradle :zb-mom-ww-mxgateway-client:jar :zb-mom-ww-mxgateway-cli:installDist
gradle :mxgateway-client:jar :mxgateway-cli:installDist
```
The library jar is under `zb-mom-ww-mxgateway-client/build/libs`. The installed CLI
distribution is under `zb-mom-ww-mxgateway-cli/build/install/zb-mom-ww-mxgateway-cli`.
The library jar is under `mxgateway-client/build/libs`. The installed CLI
distribution is under `mxgateway-cli/build/install/mxgateway-cli`.
## Integration Checks
@@ -289,40 +267,9 @@ $env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
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"
gradle :mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
```
## Installing from the Gitea Maven repository
The client publishes to the internal Gitea Maven repository at
`https://gitea.dohertylan.com/api/packages/dohertj2/maven`.
In your consumer project's `build.gradle`:
````groovy
repositories {
maven {
url 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
credentials {
username = System.getenv('GITEA_USERNAME')
password = System.getenv('GITEA_TOKEN')
}
}
}
dependencies {
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.0'
}
````
To publish a new version from this repo:
````bash
export GITEA_USERNAME=dohertj2
export GITEA_TOKEN=<your-gitea-token>
gradle :zb-mom-ww-mxgateway-client:publish
````
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
+1 -41
View File
@@ -12,7 +12,7 @@ ext {
}
subprojects {
group = 'com.zb.mom.ww.mxgateway'
group = 'com.dohertylan.mxgateway'
version = '0.1.0'
pluginManager.withPlugin('java') {
@@ -37,44 +37,4 @@ subprojects {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
}
pluginManager.withPlugin('maven-publish') {
publishing {
publications {
maven(MavenPublication) {
from components.java
pom {
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
description = 'MxAccessGateway Java client'
scm {
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
connection = 'scm:git:https://gitea.dohertylan.com/dohertj2/mxaccessgw.git'
}
developers {
developer {
id = 'dohertj2'
name = 'Joseph Doherty'
}
}
licenses {
license {
name = 'Proprietary'
distribution = 'repo'
}
}
}
}
}
repositories {
maven {
name = 'GiteaPackages'
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
credentials {
username = System.getenv('GITEA_USERNAME') ?: ''
password = System.getenv('GITEA_TOKEN') ?: ''
}
}
}
}
}
}
@@ -3,11 +3,11 @@ plugins {
}
dependencies {
implementation project(':zb-mom-ww-mxgateway-client')
implementation project(':mxgateway-client')
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
implementation "info.picocli:picocli:${picocliVersion}"
}
application {
mainClass = 'com.zb.mom.ww.mxgateway.cli.MxGatewayCli'
mainClass = 'com.dohertylan.mxgateway.cli.MxGatewayCli'
}
@@ -1,21 +1,20 @@
package com.zb.mom.ww.mxgateway.cli;
package com.dohertylan.mxgateway.cli;
import com.zb.mom.ww.mxgateway.client.DeployEventStream;
import com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient;
import com.zb.mom.ww.mxgateway.client.MxEventStream;
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
import com.zb.mom.ww.mxgateway.client.MxGatewayClient;
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
import com.zb.mom.ww.mxgateway.client.MxGatewayClientVersion;
import com.zb.mom.ww.mxgateway.client.MxGatewaySecrets;
import com.zb.mom.ww.mxgateway.client.MxGatewaySession;
import com.zb.mom.ww.mxgateway.client.MxValues;
import com.dohertylan.mxgateway.client.DeployEventStream;
import com.dohertylan.mxgateway.client.GalaxyRepositoryClient;
import com.dohertylan.mxgateway.client.MxEventStream;
import com.dohertylan.mxgateway.client.MxGatewayAlarmFeedSubscription;
import com.dohertylan.mxgateway.client.MxGatewayClient;
import com.dohertylan.mxgateway.client.MxGatewayClientOptions;
import com.dohertylan.mxgateway.client.MxGatewayClientVersion;
import com.dohertylan.mxgateway.client.MxGatewaySecrets;
import com.dohertylan.mxgateway.client.MxGatewaySession;
import com.dohertylan.mxgateway.client.MxValues;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;
import io.grpc.stub.StreamObserver;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
@@ -33,7 +32,7 @@ import java.util.Optional;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.atomic.AtomicReference;
import io.grpc.stub.StreamObserver;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
@@ -120,7 +119,7 @@ public final class MxGatewayCli implements Callable<Integer> {
return 0;
}
static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
private static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
commandLine.addSubcommand("version", new VersionCommand());
commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
@@ -155,120 +154,6 @@ public final class MxGatewayCli implements Callable<Integer> {
/** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
private static final Object ALARM_FEED_END = new Object();
/**
* Tokenises a single batch-mode stdin line into the argv that the inner
* {@link CommandLine} should execute. Honours single-quoted, double-quoted,
* and backslash-escaped runs so values that contain spaces (e.g.
* {@code --comment "needs verification"}) survive intact the old
* implementation used {@code split("\\s+")} which shredded any quoted
* argument mid-string (Client.Java-034).
*
* <p>Rules (a small POSIX-like shell tokenizer; no variable expansion,
* command substitution, globbing, or backtick handling):
*
* <ul>
* <li>Outside quotes, runs of whitespace separate tokens.</li>
* <li>{@code "..."} groups a sequence into one token; the surrounding
* quotes are removed. Inside double quotes a backslash escapes
* {@code \\}, {@code "}, and a literal newline; other characters
* are taken literally (so {@code \n} is the two characters
* backslash-n).</li>
* <li>{@code '...'} groups a sequence into one token; the surrounding
* quotes are removed. Inside single quotes nothing is escaped
* the run is literal until the matching single quote.</li>
* <li>Outside quotes, backslash escapes the next character (including
* whitespace, so {@code needs\ verification} is one token).</li>
* <li>An unterminated quote or a trailing backslash throws
* {@link IllegalArgumentException} so the batch loop surfaces it
* as a JSON error instead of silently emitting wrong args.</li>
* </ul>
*
* <p>Empty input (or input that contains only whitespace) returns an
* empty array so callers can skip the line.
*/
static String[] tokenizeBatchLine(String line) {
List<String> tokens = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inToken = false;
// 0 = outside, 1 = inside single quotes, 2 = inside double quotes
int quoteMode = 0;
int length = line.length();
for (int i = 0; i < length; i++) {
char c = line.charAt(i);
if (quoteMode == 1) {
if (c == '\'') {
quoteMode = 0;
} else {
current.append(c);
}
continue;
}
if (quoteMode == 2) {
if (c == '\\') {
if (i + 1 >= length) {
throw new IllegalArgumentException(
"batch tokenizer: trailing backslash inside double-quoted string");
}
char next = line.charAt(i + 1);
if (next == '\\' || next == '"' || next == '\n') {
current.append(next);
i++;
} else {
// POSIX rule: inside double quotes a backslash is
// literal unless it precedes \, ", $, `, or newline.
current.append(c);
}
continue;
}
if (c == '"') {
quoteMode = 0;
continue;
}
current.append(c);
continue;
}
// Outside any quotes.
if (c == '\'') {
quoteMode = 1;
inToken = true;
continue;
}
if (c == '"') {
quoteMode = 2;
inToken = true;
continue;
}
if (c == '\\') {
if (i + 1 >= length) {
throw new IllegalArgumentException(
"batch tokenizer: trailing backslash outside quotes");
}
current.append(line.charAt(i + 1));
i++;
inToken = true;
continue;
}
if (Character.isWhitespace(c)) {
if (inToken) {
tokens.add(current.toString());
current.setLength(0);
inToken = false;
}
continue;
}
current.append(c);
inToken = true;
}
if (quoteMode != 0) {
throw new IllegalArgumentException(
"batch tokenizer: unterminated " + (quoteMode == 1 ? "single" : "double") + " quote");
}
if (inToken) {
tokens.add(current.toString());
}
return tokens.toArray(new String[0]);
}
/**
* Reads one CLI invocation per stdin line, executes each via a fresh
* {@link CommandLine}, and writes {@value #BATCH_EOR} to stdout after
@@ -298,8 +183,8 @@ public final class MxGatewayCli implements Callable<Integer> {
if (line.isEmpty()) {
break;
}
String[] args = tokenizeBatchLine(line);
if (args.length == 0) {
String[] args = line.trim().split("\\s+");
if (args.length == 0 || (args.length == 1 && args[0].isEmpty())) {
continue;
}
StringWriter cmdOut = new StringWriter();
@@ -457,9 +342,12 @@ public final class MxGatewayCli implements Callable<Integer> {
if (json) {
out.println(protoJson(event));
} 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(
"seq=%d observed=%s deployTime=%s objects=%d attributes=%d%n",
event.getSequence(),
"seq=%s observed=%s deployTime=%s objects=%d attributes=%d%n",
Long.toUnsignedString(event.getSequence()),
formatTimestamp(event.getObservedAt()),
event.getTimeOfLastDeployPresent()
? formatTimestamp(event.getTimeOfLastDeploy())
@@ -756,9 +644,7 @@ public final class MxGatewayCli implements Callable<Integer> {
@Option(names = "--items", required = true, description = "Comma-separated tag addresses.")
String items;
@Option(
names = "--timeout-ms",
defaultValue = "0",
@Option(names = "--timeout-ms", defaultValue = "0",
description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
int timeoutMs;
@@ -769,8 +655,8 @@ public final class MxGatewayCli implements Callable<Integer> {
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
List<BulkReadResult> results = client.session(sessionId)
.readBulk(serverHandle, parseStringList(items), Duration.ofMillis(timeoutMs));
List<BulkReadResult> results =
client.session(sessionId).readBulk(serverHandle, parseStringList(items), timeoutMs);
writeReadBulkOutput("read-bulk", common, json, results);
}
return 0;
@@ -808,8 +694,7 @@ public final class MxGatewayCli implements Callable<Integer> {
List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) {
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());
for (int i = 0; i < handles.size(); i++) {
@@ -860,8 +745,7 @@ public final class MxGatewayCli implements Callable<Integer> {
List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) {
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));
List<Write2BulkEntry> entries = new ArrayList<>(handles.size());
@@ -914,8 +798,7 @@ public final class MxGatewayCli implements Callable<Integer> {
List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) {
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());
for (int i = 0; i < handles.size(); i++) {
@@ -970,8 +853,7 @@ public final class MxGatewayCli implements Callable<Integer> {
List<String> valueTexts = parseStringList(values);
if (handles.size() != valueTexts.size()) {
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));
List<WriteSecured2BulkEntry> entries = new ArrayList<>(handles.size());
@@ -991,113 +873,224 @@ public final class MxGatewayCli implements Callable<Integer> {
}
}
@Command(
name = "bench-read-bulk",
description = "Repeatedly invokes ReadBulk for benchmarking; prints aggregate timing.")
/**
* Cross-language ReadBulk stress benchmark mirrors the .NET / Go / Rust /
* Python implementations so the PS driver collates one JSON schema across
* all five clients.
*/
@Command(name = "bench-read-bulk", description = "Cross-language ReadBulk stress benchmark.")
static final class BenchReadBulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--client-name", defaultValue = "mxgw-java-bench")
String clientName;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--duration-seconds", defaultValue = "30")
int durationSeconds;
@Option(names = "--items", required = true, description = "Comma-separated tag addresses.")
String items;
@Option(names = "--warmup-seconds", defaultValue = "3")
int warmupSeconds;
@Option(
names = "--timeout-ms",
defaultValue = "0",
description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
@Option(names = "--bulk-size", defaultValue = "6")
int bulkSize;
@Option(names = "--tag-start", defaultValue = "1")
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;
@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) {
super(clientFactory);
}
@Override
public Integer call() {
if (iterations <= 0) {
throw new IllegalArgumentException("--iterations must be positive");
if (bulkSize < 1) {
throw new IllegalArgumentException("bulk-size must be positive");
}
if (warmup < 0) {
throw new IllegalArgumentException("--warmup must be non-negative");
List<String> tags = new ArrayList<>(bulkSize);
for (int i = 0; i < bulkSize; i++) {
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())) {
var openReply = client.openSession(
mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest.newBuilder()
.setClientSessionName(clientName)
.build());
String sessionId = openReply.getSessionId();
MxGatewayCliSession session = client.session(sessionId);
for (int i = 0; i < warmup; i++) {
session.readBulk(serverHandle, tagAddresses, timeout);
}
long totalNanos = 0L;
long minNanos = Long.MAX_VALUE;
long maxNanos = 0L;
int lastResultCount = 0;
int lastSuccessCount = 0;
int lastCachedCount = 0;
for (int i = 0; i < iterations; i++) {
long start = System.nanoTime();
List<BulkReadResult> results = session.readBulk(serverHandle, tagAddresses, timeout);
long elapsed = System.nanoTime() - start;
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++;
List<Integer> itemHandles = new ArrayList<>();
long steadyElapsedNanos;
long[] latenciesNanos;
int latencyCount = 0;
long successful = 0;
long failed = 0;
long totalResults = 0;
long cachedResults = 0;
int serverHandle = session.register(clientName);
try {
List<SubscribeResult> subscribeResults = session.subscribeBulk(serverHandle, tags);
for (SubscribeResult r : subscribeResults) {
if (r.getWasSuccessful()) {
itemHandles.add(r.getItemHandle());
}
}
// 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);
}
latenciesNanos = new long[Math.max(1024, durationSeconds * 1000)];
long steadyStart = System.nanoTime();
long steadyDeadline = steadyStart + durationSeconds * 1_000_000_000L;
while (System.nanoTime() < steadyDeadline) {
long callStart = System.nanoTime();
try {
List<BulkReadResult> results = session.readBulk(serverHandle, tags, timeoutMs);
long elapsed = System.nanoTime() - callStart;
// Only record successful-call latencies including failed-call
// durations would pollute the p50/p95/p99 percentile summary
// (Client.Java-024, mirrors Client.Rust-015). The cross-language
// bench matrix expects success-only latency histograms.
if (latencyCount >= latenciesNanos.length) {
long[] grown = new long[latenciesNanos.length * 2];
System.arraycopy(latenciesNanos, 0, grown, 0, latencyCount);
latenciesNanos = grown;
}
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) { }
}
double avgMs = totalNanos / 1_000_000.0 / iterations;
double minMs = minNanos / 1_000_000.0;
double maxMs = maxNanos / 1_000_000.0;
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "bench-read-bulk");
output.put("options", common.redactedJsonMap());
output.put("iterations", iterations);
output.put("warmup", warmup);
output.put("tagCount", tagAddresses.size());
output.put("resultCount", lastResultCount);
output.put("successCount", lastSuccessCount);
output.put("cachedCount", lastCachedCount);
output.put("avgMs", avgMs);
output.put("minMs", minMs);
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);
}
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;
}
}
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.")
static final class WriteCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
@@ -1158,7 +1151,13 @@ public final class MxGatewayCli implements Callable<Integer> {
if (json) {
client.out().println(protoJson(event));
} else {
client.out().printf("%d %s%n", event.getWorkerSequence(), event.getFamily());
// worker_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.
client.out().printf(
"%s %s%n",
Long.toUnsignedString(event.getWorkerSequence()),
event.getFamily());
}
count++;
if (limit > 0 && count >= limit) {
@@ -1194,29 +1193,11 @@ public final class MxGatewayCli implements Callable<Integer> {
StreamAlarmsRequest request = StreamAlarmsRequest.newBuilder()
.setAlarmFilterPrefix(filterPrefix)
.build();
// Client.Java-033 fail-fast on overflow. A bare
// queue.offer(value) silently drops messages past capacity,
// which violates the JavaStyleGuide "do not drop events"
// contract and lets the CLI exit 0 on a truncated feed.
// Mirrors MxEventStream's overflow branch: detect a failed
// offer, cancel the subscription, drain the buffer, then
// queue an explicit overflow exception followed by the END
// sentinel so the drain loop surfaces a non-zero exit.
AtomicReference<MxGatewayAlarmFeedSubscription> subscriptionRef = new AtomicReference<>();
MxGatewayAlarmFeedSubscription subscription =
client.streamAlarms(request, new StreamObserver<>() {
@Override
public void onNext(AlarmFeedMessage value) {
if (!queue.offer(value)) {
MxGatewayAlarmFeedSubscription sub = subscriptionRef.get();
if (sub != null) {
sub.cancel();
}
queue.clear();
queue.offer(new IllegalStateException(
"stream-alarms queue overflowed (capacity 1024); consumer too slow"));
queue.offer(ALARM_FEED_END);
}
queue.offer(value);
}
@Override
@@ -1229,7 +1210,6 @@ public final class MxGatewayCli implements Callable<Integer> {
queue.offer(ALARM_FEED_END);
}
});
subscriptionRef.set(subscription);
try {
int count = 0;
while (true) {
@@ -1369,38 +1349,93 @@ public final class MxGatewayCli implements Callable<Integer> {
@Option(names = "--timeout", defaultValue = "30s", description = "Per-call timeout.")
String timeout;
private String resolvedApiKey = "";
private Duration resolvedTimeout = Duration.ofSeconds(30);
@Option(
names = "--shutdown-timeout",
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() {
resolvedApiKey = apiKey == null || apiKey.isBlank() ? System.getenv(apiKeyEnv) : apiKey;
if (resolvedApiKey == null) {
resolvedApiKey = "";
}
resolvedTimeout = parseDuration(timeout);
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() {
return MxGatewayClientOptions.builder()
MxGatewayClientOptions.Builder builder = MxGatewayClientOptions.builder()
.endpoint(endpoint)
.apiKey(resolvedApiKey)
.apiKey(resolvedApiKey())
.plaintext(plaintext)
.caCertificatePath(caFile)
.serverNameOverride(serverNameOverride)
.callTimeout(resolvedTimeout)
.build();
.callTimeout(resolvedTimeout());
Duration resolvedShutdownTimeout = resolvedShutdownTimeout();
if (resolvedShutdownTimeout != null) {
builder.shutdownTimeout(resolvedShutdownTimeout);
}
return builder.build();
}
Map<String, Object> redactedJsonMap() {
Map<String, Object> values = new LinkedHashMap<>();
values.put("endpoint", endpoint);
values.put("apiKey", MxGatewaySecrets.redactApiKey(resolvedApiKey));
values.put("apiKey", MxGatewaySecrets.redactApiKey(resolvedApiKey()));
values.put("apiKeyEnv", apiKeyEnv);
values.put("plaintext", plaintext);
values.put("caFile", caFile == null ? "" : caFile.toString());
values.put("serverNameOverride", serverNameOverride);
values.put("timeout", timeout);
Duration resolvedShutdownTimeout = resolvedShutdownTimeout();
values.put("shutdownTimeout", resolvedShutdownTimeout == null ? "" : resolvedShutdownTimeout.toString());
return values;
}
}
@@ -1446,7 +1481,7 @@ public final class MxGatewayCli implements Callable<Integer> {
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout);
List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs);
List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries);
@@ -1559,8 +1594,8 @@ public final class MxGatewayCli implements Callable<Integer> {
}
@Override
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout) {
return session.readBulk(serverHandle, items, timeout);
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs) {
return session.readBulk(serverHandle, items, timeoutMs);
}
@Override
@@ -1,17 +1,16 @@
package com.zb.mom.ww.mxgateway.cli;
package com.dohertylan.mxgateway.cli;
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 com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
import io.grpc.stub.StreamObserver;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.PrintWriter;
import java.io.StringWriter;
import com.dohertylan.mxgateway.client.MxGatewayAlarmFeedSubscription;
import io.grpc.stub.StreamObserver;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
@@ -33,10 +32,10 @@ import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
@@ -82,8 +81,10 @@ final class MxGatewayCliTests {
assertEquals(0, run.exitCode());
assertTrue(run.output().contains("\"command\":\"open-session\""));
assertTrue(run.output().contains("\"sessionId\":\"session-cli\""));
assertTrue(run.output().contains("mxgw***********cret"));
// Only the non-secret mxgw_<key-id>_ prefix survives; the secret is fully masked.
assertTrue(run.output().contains("mxgw_visible_***"));
assertFalse(run.output().contains("visible_secret"));
assertFalse(run.output().contains("cret"));
}
@Test
@@ -142,6 +143,40 @@ final class MxGatewayCliTests {
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
void unsubscribeBulkCommandPrintsResults() {
CliRun run = execute(
@@ -161,6 +196,209 @@ final class MxGatewayCliTests {
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 ----
@Test
@@ -225,175 +463,73 @@ final class MxGatewayCliTests {
assertTrue(run.errors().contains("--reference"), run.errors());
}
@Test
void readmeDocumentedStreamAlarmsExampleParsesCleanly() {
// Client.Java-032 regression the README's stream-alarms example
// (clients/java/README.md:182) must round-trip through picocli's
// parser without a parse error. Before the fix, the example used
// a non-existent --session-id option and picocli failed at parse
// time. This test pins the exact tokens documented in the README.
String[] args = {
"stream-alarms",
"--endpoint",
"localhost:5000",
"--api-key-env",
"MXGATEWAY_API_KEY",
"--plaintext",
"--filter-prefix",
"Galaxy",
"--limit",
"1",
"--json"
};
assertReadmeExampleParses(args);
}
// ---- Client.Java-027: batch subcommand ----
@Test
void readmeDocumentedAcknowledgeAlarmExampleParsesCleanly() {
// Client.Java-032 regression the README's acknowledge-alarm
// example (clients/java/README.md:183) must parse without error.
// Before the fix it used --session-id (no such option) and
// --alarm-reference (the real option is --reference), so picocli
// rejected the invocation immediately.
String[] args = {
"acknowledge-alarm",
"--endpoint",
"localhost:5000",
"--api-key-env",
"MXGATEWAY_API_KEY",
"--plaintext",
"--reference",
"\\Galaxy\\Area001.Pump001.PumpFault",
"--json"
};
assertReadmeExampleParses(args);
}
/**
* Parses the given args through the production picocli {@link CommandLine}
* and asserts no parser error, no unknown option, and no missing required
* option. Does not execute the command body only the option / subcommand
* parser is exercised, so no network call is made.
*/
private static void assertReadmeExampleParses(String[] args) {
picocli.CommandLine commandLine = MxGatewayCli.commandLine(new FakeClientFactory());
try {
commandLine.parseArgs(args);
} catch (picocli.CommandLine.ParameterException ex) {
throw new AssertionError(
"documented README invocation failed picocli parse: "
+ String.join(" ", args)
+ " -> "
+ ex.getMessage(),
ex);
}
}
@Test
void streamAlarmsCommandFailsFastOnQueueOverflow() {
// Client.Java-033 regression the CLI's stream-alarms bounded queue
// used queue.offer(value) which silently dropped messages past
// capacity (1024). After the fix the CLI must surface the overflow
// as a non-zero exit (mirroring MxEventStream's fail-fast contract).
//
// The OverflowingFakeClient floods the gRPC observer with 2000
// messages synchronously, which exceeds the bounded 1024-element
// queue. The fix detects the failed offer, cancels the subscription,
// queues an overflow exception, and the drain loop surfaces it.
OverflowingFakeClientFactory factory = new OverflowingFakeClientFactory();
CliRun run = execute(factory, "stream-alarms", "--filter-prefix", "Flood");
assertFalse(run.exitCode() == 0,
"expected non-zero exit when the alarm queue overflows; got exit=" + run.exitCode()
+ " out=\n" + run.output() + "\nerr=\n" + run.errors());
}
@Test
void batchCommandExecutesVersionAndEmitsEorMarker() {
CliRun run = executeBatch(new FakeClientFactory(), "version --json\n");
void batchCommandExecutesTwoCommandsAndEmitsEorAfterEach() {
String stdin = "version --json\nversion --json\n";
CliRun run = executeBatch(new FakeClientFactory(), stdin);
assertEquals(0, run.exitCode());
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(MxGatewayCli.BATCH_EOR), out);
}
@Test
void batchCommandTokenisesDoubleQuotedArgumentWithEmbeddedSpaces() {
// Client.Java-034 regression a real shell-style tokenizer must not
// shred `"needs verification"` into two arguments. Drives
// acknowledge-alarm through batch and asserts the captured --comment
// is the un-quoted string with the embedded space preserved.
FakeClientFactory factory = new FakeClientFactory();
String line = "acknowledge-alarm --reference Tank01.Level.HiHi --comment \"needs verification\" --operator op1\n";
CliRun run = executeBatch(factory, line);
void batchCommandEmitsEorOnFailedCommand() {
// "open-session" without --endpoint / --api-key-env will fail against
// the FakeClientFactory (missing required option --session-id for
// close-session, for example). Use an unknown subcommand to provoke a
// picocli parse error which produces a non-zero exit code without
// hitting the gateway.
String stdin = "no-such-subcommand\nversion --json\n";
CliRun run = executeBatch(new FakeClientFactory(), stdin);
assertEquals(0, run.exitCode());
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
assertEquals("op1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
assertEquals(
"Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
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 batchCommandTokenisesSingleQuotedArgumentWithEmbeddedSpaces() {
FakeClientFactory factory = new FakeClientFactory();
String line =
"acknowledge-alarm --reference Tank01.Level.HiHi --comment 'needs verification' --operator op1\n";
CliRun run = executeBatch(factory, line);
void batchCommandExitsZeroOnEmptyLine() {
// An empty line signals EOF-equivalent; loop exits immediately.
CliRun run = executeBatch(new FakeClientFactory(), "\n");
assertEquals(0, run.exitCode());
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
}
@Test
void batchCommandTokenisesBackslashEscapedSpaceOutsideQuotes() {
FakeClientFactory factory = new FakeClientFactory();
String line =
"acknowledge-alarm --reference Tank01.Level.HiHi --comment needs\\ verification\n";
CliRun run = executeBatch(factory, line);
void batchCommandExitsZeroOnActualEof() {
CliRun run = executeBatch(new FakeClientFactory(), "");
assertEquals(0, run.exitCode());
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
}
@Test
void batchCommandPreservesEmptyQuotedArgument() {
FakeClientFactory factory = new FakeClientFactory();
String line = "acknowledge-alarm --reference Tank01.Level.HiHi --comment \"\"\n";
CliRun run = executeBatch(factory, line);
assertEquals(0, run.exitCode());
assertEquals("", factory.client.lastAcknowledgeAlarmRequest.getComment());
}
@Test
void batchCommandSupportsBackslashEscapedQuoteInsideDoubleQuotes() {
// `--comment "with \"inner\" quote"` should round-trip the inner
// double-quote into the comment string.
FakeClientFactory factory = new FakeClientFactory();
String line =
"acknowledge-alarm --reference Tank01.Level.HiHi --comment \"with \\\"inner\\\" quote\"\n";
CliRun run = executeBatch(factory, line);
assertEquals(0, run.exitCode());
assertEquals("with \"inner\" quote", factory.client.lastAcknowledgeAlarmRequest.getComment());
}
@Test
void batchCommandEmitsEorAfterFailedCommandAndContinues() {
// An unknown subcommand causes a picocli parse error (non-zero exit).
// The loop must still emit BATCH_EOR for the failure and continue
// processing the subsequent valid command.
CliRun run = executeBatch(new FakeClientFactory(), "no-such-subcommand\nversion --json\n");
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());
String out = run.output();
long eorCount = out.lines()
.filter(l -> l.equals(MxGatewayCli.BATCH_EOR))
.count();
assertEquals(2, eorCount, "expected exactly 2 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out);
assertTrue(out.contains("\"clientVersion\""), out);
assertEquals(3, eorCount, "expected exactly 3 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out);
}
/**
@@ -435,77 +571,6 @@ final class MxGatewayCliTests {
}
}
/**
* Factory whose fake client floods the {@code streamAlarms} observer with
* 2000 messages synchronously, exceeding the CLI's bounded 1024-element
* queue. Used by the Client.Java-033 fail-fast overflow regression.
*/
private static final class OverflowingFakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
@Override
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
return new OverflowingFakeClient(options.spec.commandLine().getOut());
}
}
private static final class OverflowingFakeClient implements MxGatewayCli.MxGatewayCliClient {
private final PrintWriter out;
OverflowingFakeClient(PrintWriter out) {
this.out = out;
}
@Override
public PrintWriter out() {
return out;
}
@Override
public OpenSessionReply openSession(OpenSessionRequest request) {
return OpenSessionReply.newBuilder().setSessionId("flood-session").setProtocolStatus(ok()).build();
}
@Override
public CloseSessionReply closeSession(CloseSessionRequest request) {
return CloseSessionReply.newBuilder()
.setSessionId(request.getSessionId())
.setFinalState(SessionState.SESSION_STATE_CLOSED)
.setProtocolStatus(ok())
.build();
}
@Override
public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
throw new UnsupportedOperationException();
}
@Override
public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
throw new UnsupportedOperationException();
}
@Override
public MxGatewayAlarmFeedSubscription streamAlarms(
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
// Synchronously push 2000 messages to overflow the CLI's bounded
// 1024-element queue. The CLI must surface the overflow rather
// than silently dropping the trailing ~976 messages.
for (int i = 0; i < 2000; i++) {
observer.onNext(AlarmFeedMessage.newBuilder()
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
.setAlarmFullReference("Flood." + i)
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
.setSeverity(700))
.build());
}
observer.onCompleted();
return new MxGatewayAlarmFeedSubscription();
}
@Override
public void close() {
}
}
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
private final PrintWriter out;
private final FakeSession session = new FakeSession();
@@ -672,8 +737,21 @@ final class MxGatewayCliTests {
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
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout) {
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, int timeoutMs) {
lastReadBulkTimeoutMs = timeoutMs;
lastReadBulkItems = items;
List<BulkReadResult> results = new ArrayList<>();
for (int index = 0; index < items.size(); index++) {
results.add(BulkReadResult.newBuilder()
@@ -681,7 +759,8 @@ final class MxGatewayCliTests {
.setTagAddress(items.get(index))
.setItemHandle(200 + index)
.setWasSuccessful(true)
.setWasCached(true)
.setWasCached(index % 2 == 0)
.setQuality(192)
.build());
}
return results;
@@ -689,6 +768,7 @@ final class MxGatewayCliTests {
@Override
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
lastWriteBulkEntries = entries;
List<BulkWriteResult> results = new ArrayList<>();
for (WriteBulkEntry entry : entries) {
results.add(BulkWriteResult.newBuilder()
@@ -702,6 +782,7 @@ final class MxGatewayCliTests {
@Override
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
lastWrite2BulkEntries = entries;
List<BulkWriteResult> results = new ArrayList<>();
for (Write2BulkEntry entry : entries) {
results.add(BulkWriteResult.newBuilder()
@@ -715,6 +796,7 @@ final class MxGatewayCliTests {
@Override
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
lastWriteSecuredBulkEntries = entries;
List<BulkWriteResult> results = new ArrayList<>();
for (WriteSecuredBulkEntry entry : entries) {
results.add(BulkWriteResult.newBuilder()
@@ -728,6 +810,7 @@ final class MxGatewayCliTests {
@Override
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
lastWriteSecured2BulkEntries = entries;
List<BulkWriteResult> results = new ArrayList<>();
for (WriteSecured2BulkEntry entry : entries) {
results.add(BulkWriteResult.newBuilder()
@@ -740,7 +823,7 @@ final class MxGatewayCliTests {
}
@Override
public com.zb.mom.ww.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
public com.dohertylan.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
throw new UnsupportedOperationException("stream-events is covered by client tests");
}
}
@@ -1,7 +1,6 @@
plugins {
id 'java-library'
id 'com.google.protobuf'
id 'maven-publish'
}
dependencies {
@@ -23,7 +22,7 @@ dependencies {
sourceSets {
main {
proto {
srcDir rootProject.file('../../src/ZB.MOM.WW.MxGateway.Contracts/Protos')
srcDir rootProject.file('../../src/MxGateway.Contracts/Protos')
include 'mxaccess_gateway.proto'
include 'mxaccess_worker.proto'
include 'galaxy_repository.proto'
@@ -31,11 +30,6 @@ sourceSets {
}
}
java {
withSourcesJar()
withJavadocJar()
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:${protobufVersion}"
@@ -1,4 +1,4 @@
package com.zb.mom.ww.mxgateway.client;
package com.dohertylan.mxgateway.client;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
@@ -11,20 +11,29 @@ import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming
* RPC. Mirrors {@link MxEventStream}: events arrive on a background gRPC thread
* and are buffered in a bounded blocking queue; the iterator drains them.
* 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 {
private static final Object END = new Object();
private final BlockingQueue<Object> queue;
private final AtomicBoolean closed = new AtomicBoolean();
private final Object terminalLock = new Object();
private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream;
private volatile boolean closed;
private boolean terminated;
private Object next;
DeployEventStream(int capacity) {
@@ -36,7 +45,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
@Override
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> requestStream) {
DeployEventStream.this.requestStream = requestStream;
if (closed.get()) {
if (closed) {
requestStream.cancel("client cancelled deploy event stream", null);
}
}
@@ -48,7 +57,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
@Override
public void onError(Throwable error) {
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed.get()) {
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed) {
offer(END);
return;
}
@@ -94,12 +103,12 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
@Override
public void close() {
closed.set(true);
closed = true;
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
if (stream != null) {
stream.cancel("client cancelled deploy event stream", null);
}
offer(END);
terminate(null);
}
private Object take() {
@@ -117,10 +126,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
private void offer(Object value) {
Objects.requireNonNull(value, "value");
if (value == END) {
if (!queue.offer(value)) {
queue.clear();
queue.offer(value);
}
terminate(null);
return;
}
if (!queue.offer(value)) {
@@ -128,9 +134,40 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
if (stream != null) {
stream.cancel("client deploy event stream queue overflowed", null);
}
queue.clear();
queue.offer(new MxGatewayException("galaxy watch deploy events queue overflowed"));
queue.offer(END);
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.offer(END);
}
}
}
}
@@ -0,0 +1,65 @@
package com.dohertylan.mxgateway.client;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
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;
/**
* Cancellable handle returned by the async {@code watchDeployEvents} variant.
* Mirrors {@link MxGatewayEventSubscription} but for the Galaxy Repository
* deploy-event stream.
*/
public final class DeployEventSubscription implements AutoCloseable {
private final AtomicReference<ClientCallStreamObserver<WatchDeployEventsRequest>> requestStream =
new AtomicReference<>();
private final AtomicBoolean cancelled = new AtomicBoolean();
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> wrap(StreamObserver<DeployEvent> observer) {
return new ClientResponseObserver<>() {
@Override
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> stream) {
requestStream.set(stream);
if (cancelled.get()) {
stream.cancel("client cancelled deploy event stream", null);
}
}
@Override
public void onNext(DeployEvent 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<WatchDeployEventsRequest> stream = requestStream.get();
if (stream != null) {
stream.cancel("client cancelled deploy event stream", null);
}
}
@Override
public void close() {
cancel();
}
}
@@ -1,11 +1,6 @@
package com.zb.mom.ww.mxgateway.client;
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.MoreExecutors;
import galaxy_repository.v1.GalaxyRepositoryGrpc;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
@@ -19,8 +14,6 @@ import com.google.protobuf.Timestamp;
import io.grpc.Channel;
import io.grpc.ClientInterceptors;
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 java.time.Instant;
import java.util.Iterator;
@@ -28,8 +21,7 @@ import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLException;
import java.util.concurrent.atomic.AtomicReference;
/**
* Thin wrapper around the generated {@link GalaxyRepositoryGrpc} stubs that
@@ -39,7 +31,6 @@ import javax.net.ssl.SSLException;
*/
public final class GalaxyRepositoryClient implements AutoCloseable {
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
private static final int BROWSE_CHILDREN_PAGE_SIZE = 500;
private final ManagedChannel ownedChannel;
private final MxGatewayClientOptions options;
@@ -81,7 +72,8 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* @return a connected client
*/
public static GalaxyRepositoryClient connect(MxGatewayClientOptions options) {
return new GalaxyRepositoryClient(createChannel(options), options);
return new GalaxyRepositoryClient(
MxGatewayChannels.createChannel(options, "failed to configure galaxy repository TLS"), options);
}
/**
@@ -90,7 +82,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* @return the blocking stub
*/
public GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub rawBlockingStub() {
return withDeadline(blockingStub);
return MxGatewayChannels.withDeadline(blockingStub, options);
}
/**
@@ -99,7 +91,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* @return the future stub
*/
public GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub rawFutureStub() {
return withDeadline(futureStub);
return MxGatewayChannels.withDeadline(futureStub, options);
}
/**
@@ -136,8 +128,14 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* exceptionally with {@link MxGatewayException} on failure
*/
public CompletableFuture<Boolean> testConnectionAsync() {
return toCompletable(rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()))
.thenApply(TestConnectionReply::getOk);
// Apply the projection inside toCompletable rather than via .thenApply
// so the user-visible future is the same future cancellation is bound
// 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);
}
/**
@@ -168,8 +166,10 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* completed exceptionally with {@link MxGatewayException} on failure
*/
public CompletableFuture<Optional<Instant>> getLastDeployTimeAsync() {
return toCompletable(rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()))
.thenApply(GalaxyRepositoryClient::mapDeployTime);
return MxGatewayChannels.toCompletable(
rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()),
"galaxy get last deploy time",
GalaxyRepositoryClient::mapDeployTime);
}
/**
@@ -213,99 +213,33 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
* exceptionally with {@link MxGatewayException} on failure
*/
public CompletableFuture<List<GalaxyObject>> discoverHierarchyAsync() {
return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>());
}
/**
* Lazy-browse entry point: fetches the root layer of the Galaxy hierarchy.
* Each returned {@link LazyBrowseNode} can be expanded on demand via
* {@link LazyBrowseNode#expand()} to load its direct children.
*
* @return the root nodes (no parent selector) with default options
* @throws MxGatewayException on transport or protocol failure
*/
public List<LazyBrowseNode> browse() {
return browse(null);
}
/**
* Lazy-browse entry point with caller-supplied filters / shape.
*
* @param options filter and shape options; {@code null} means {@link BrowseChildrenOptions#empty()}
* @return the root nodes matching the options
* @throws MxGatewayException on transport or protocol failure
*/
public List<LazyBrowseNode> browse(BrowseChildrenOptions options) {
BrowseChildrenOptions effective = options == null ? BrowseChildrenOptions.empty() : options;
return browseChildrenInner(null, effective);
}
/**
* Issues a single {@code BrowseChildren} RPC and returns the raw reply.
* Callers wanting full control over pagination can drive the loop themselves.
*
* @param request the request to send
* @return the reply
* @throws MxGatewayException on transport or protocol failure
*/
public BrowseChildrenReply browseChildrenRaw(BrowseChildrenRequest request) {
try {
return rawBlockingStub().browseChildren(request);
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
// The recursive page chain produces a fresh in-flight RPC per page.
// 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;
}
throw MxGatewayErrors.fromGrpc("galaxy browse children", error);
}
}
/**
* Drives the BrowseChildren paging loop for a single parent (or roots when
* {@code parentGobjectId} is {@code null}). Detects repeated page tokens to
* avoid infinite loops on a buggy server.
*/
List<LazyBrowseNode> browseChildrenInner(Integer parentGobjectId, BrowseChildrenOptions options) {
java.util.ArrayList<LazyBrowseNode> nodes = new java.util.ArrayList<>();
java.util.HashSet<String> seenPageTokens = new java.util.HashSet<>();
String pageToken = "";
while (true) {
BrowseChildrenRequest.Builder builder = BrowseChildrenRequest.newBuilder()
.setPageSize(BROWSE_CHILDREN_PAGE_SIZE)
.setPageToken(pageToken)
.setAlarmBearingOnly(options.isAlarmBearingOnly())
.setHistorizedOnly(options.isHistorizedOnly());
if (parentGobjectId != null) {
builder.setParentGobjectId(parentGobjectId.intValue());
}
if (!options.getCategoryIds().isEmpty()) {
builder.addAllCategoryIds(options.getCategoryIds());
}
if (!options.getTemplateChainContains().isEmpty()) {
builder.addAllTemplateChainContains(options.getTemplateChainContains());
}
if (!options.getTagNameGlob().isEmpty()) {
builder.setTagNameGlob(options.getTagNameGlob());
}
if (options.getIncludeAttributes() != null) {
builder.setIncludeAttributes(options.getIncludeAttributes());
}
BrowseChildrenReply reply = browseChildrenRaw(builder.build());
for (int i = 0; i < reply.getChildrenCount(); i++) {
boolean hint = i < reply.getChildHasChildrenCount() && reply.getChildHasChildren(i);
nodes.add(new LazyBrowseNode(this, reply.getChildren(i), hint, options));
}
pageToken = reply.getNextPageToken();
if (pageToken == null || pageToken.isEmpty()) {
return nodes;
}
if (!seenPageTokens.add(pageToken)) {
throw new MxGatewayException(
"galaxy browse children returned repeated page token: " + pageToken);
}
}
};
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;
}
/**
@@ -319,7 +253,8 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
*/
public DeployEventStream watchDeployEvents(Instant lastSeenDeployTime) {
DeployEventStream stream = new DeployEventStream(16);
withStreamDeadline(rawAsyncStub()).watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
return stream;
}
@@ -348,7 +283,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
Instant lastSeenDeployTime, StreamObserver<DeployEvent> observer) {
Objects.requireNonNull(observer, "observer");
DeployEventSubscription subscription = new DeployEventSubscription();
withStreamDeadline(rawAsyncStub())
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), subscription.wrap(observer));
return subscription;
}
@@ -364,34 +299,35 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
return builder.build();
}
private <T extends io.grpc.stub.AbstractStub<T>> T withStreamDeadline(T stub) {
if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
/**
* Shuts the owned channel down and awaits termination so try-with-resources
* callers do not leave in-flight calls or Netty event-loop threads running
* after the block exits.
*
* <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
public void close() {
if (ownedChannel != null) {
ownedChannel.shutdown();
}
MxGatewayChannels.shutdown(ownedChannel, options);
}
/**
* Shuts the owned channel down and waits up to the configured connect
* timeout for termination, forcibly shutting it down on timeout. No-op
* for clients that do not own their channel.
* Shuts the owned channel down and waits up to
* {@link MxGatewayClientOptions#shutdownTimeout()} for termination,
* forcibly shutting it down on timeout. No-op for clients that do not own
* their channel.
*
* @throws InterruptedException if the calling thread is interrupted while waiting
*/
public void closeAndAwaitTermination() throws InterruptedException {
if (ownedChannel != null) {
ownedChannel.shutdown();
if (!ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
ownedChannel.shutdownNow();
}
}
MxGatewayChannels.shutdownAndAwaitTermination(ownedChannel, options);
}
private static Optional<Instant> mapDeployTime(GetLastDeployTimeReply reply) {
@@ -402,47 +338,22 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
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(
String pageToken, java.util.ArrayList<GalaxyObject> objects, java.util.HashSet<String> seenPageTokens) {
String pageToken,
java.util.ArrayList<GalaxyObject> objects,
java.util.HashSet<String> seenPageTokens,
AtomicReference<CompletableFuture<?>> currentStage) {
DiscoverHierarchyRequest request = DiscoverHierarchyRequest.newBuilder()
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
.setPageToken(pageToken)
.build();
return toCompletable(rawFutureStub().discoverHierarchy(request)).thenCompose(reply -> {
CompletableFuture<DiscoverHierarchyReply> pageFuture = MxGatewayChannels.toCompletable(
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());
if (reply.getNextPageToken().isBlank()) {
return CompletableFuture.completedFuture(objects);
@@ -450,38 +361,11 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
if (!seenPageTokens.add(reply.getNextPageToken())) {
CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>();
failed.completeExceptionally(new MxGatewayException(
"galaxy discover hierarchy returned repeated page token: " + reply.getNextPageToken()));
"galaxy discover hierarchy returned repeated page token: "
+ reply.getNextPageToken()));
return failed;
}
return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens);
return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens, currentStage);
});
}
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.zb.mom.ww.mxgateway.client;
package com.dohertylan.mxgateway.client;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
@@ -1,4 +1,4 @@
package com.zb.mom.ww.mxgateway.client;
package com.dohertylan.mxgateway.client;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
@@ -21,13 +21,38 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
* stream cancels the underlying gRPC call. If the queue overflows the call is
* cancelled and a follow-up call to {@link #next()} throws
* {@link MxGatewayException}.
*
* <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 {
private static final Object END = new Object();
private final BlockingQueue<Object> queue;
private final Object terminalLock = new Object();
private volatile ClientCallStreamObserver<StreamEventsRequest> requestStream;
private volatile boolean closed;
private boolean terminated;
private Object next;
MxEventStream(int capacity) {
@@ -38,7 +63,16 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
return new ClientResponseObserver<>() {
@Override
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;
if (closed) {
requestStream.cancel("client cancelled event stream", null);
}
}
@Override
@@ -98,7 +132,7 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
if (stream != null) {
stream.cancel("client cancelled event stream", null);
}
offer(END);
terminate(null);
}
private Object take() {
@@ -115,10 +149,7 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
private void offer(Object value) {
Objects.requireNonNull(value, "value");
if (value == END) {
if (!queue.offer(value)) {
queue.clear();
queue.offer(value);
}
terminate(null);
return;
}
if (!queue.offer(value)) {
@@ -126,9 +157,38 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
if (stream != null) {
stream.cancel("client event stream queue overflowed", null);
}
queue.clear();
queue.offer(new MxGatewayException("gateway stream events queue overflowed"));
queue.offer(END);
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.offer(END);
}
}
}
}
@@ -0,0 +1,67 @@
package com.dohertylan.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.AlarmFeedMessage;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
/**
* Cancellable handle returned by {@code streamAlarms}.
*
* <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 MxGatewayAlarmFeedSubscription implements AutoCloseable {
private final AtomicReference<ClientCallStreamObserver<StreamAlarmsRequest>> requestStream = new AtomicReference<>();
private final AtomicBoolean cancelled = new AtomicBoolean();
ClientResponseObserver<StreamAlarmsRequest, AlarmFeedMessage> wrap(StreamObserver<AlarmFeedMessage> observer) {
return new ClientResponseObserver<>() {
@Override
public void beforeStart(ClientCallStreamObserver<StreamAlarmsRequest> stream) {
requestStream.set(stream);
if (cancelled.get()) {
stream.cancel("client cancelled alarm feed", null);
}
}
@Override
public void onNext(AlarmFeedMessage 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<StreamAlarmsRequest> stream = requestStream.get();
if (stream != null) {
stream.cancel("client cancelled alarm feed", null);
}
}
@Override
public void close() {
cancel();
}
}
@@ -1,4 +1,4 @@
package com.zb.mom.ww.mxgateway.client;
package com.dohertylan.mxgateway.client;
import io.grpc.CallOptions;
import io.grpc.Channel;

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