Files
mxaccessgw/clients/dotnet
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
..

.NET Client Projects

The .NET client workspace contains the MXAccess Gateway client library, test CLI, and unit tests.

Projects

Project Purpose
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/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.

Build And Test

dotnet build clients/dotnet/MxGateway.Client.sln
dotnet test clients/dotnet/MxGateway.Client.sln --no-build

Packaging

Create local library and CLI artifacts from the repository root:

$dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/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 published CLI runs from artifacts/clients/dotnet/mxgw-dotnet.

Regenerating Protobuf Bindings

The .NET client uses the generated C# types from src/MxGateway.Contracts/Generated. Regenerate those files through the contracts project:

dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj

Client Usage

MxGatewayClient opens a gRPC channel to the gateway and attaches the API key to every unary and streaming call as authorization: Bearer <api-key>. Cancellation tokens passed to the public methods flow to the generated gRPC call. Client-side cancellation stops waiting for the gateway response; it does not abort an MXAccess COM call that is already executing inside a worker.

await using MxGatewayClient client = MxGatewayClient.Create(
    new MxGatewayClientOptions
    {
        Endpoint = new Uri("http://localhost:5000"),
        ApiKey = apiKey,
    });

MxGatewaySession session = await client.OpenSessionAsync();
try
{
    int serverHandle = await session.RegisterAsync("sample-client");
    int itemHandle = await session.AddItemAsync(
        serverHandle,
        "Area001.Pump001.Speed");

    await session.AdviseAsync(serverHandle, itemHandle);
}
finally
{
    await session.CloseAsync();
}

Use OpenSessionRawAsync, CloseSessionRawAsync, InvokeAsync, and StreamEventsAsync when tests or parity tools need direct generated protobuf messages. MxGatewaySession.OpenSessionReply keeps the raw session-open reply available, and command helpers have *RawAsync variants when callers need the complete MxCommandReply.

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.

// 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.

Values, Status, And Errors

The client provides extension helpers for generated protobuf values. Use ToMxValue() on .NET scalar values and typed arrays to create MxValue instances for Write and Write2. Use ToClrValue() and GetProjectionKind() when test or diagnostic code needs to inspect generated MxValue replies while preserving rawDiagnostic, raw data type fields, and raw byte payloads.

MxStatusProxy.IsSuccess() and ToDiagnosticSummary() expose MXAccess status arrays without collapsing them into a single gateway success flag. Command reply helpers follow the same split:

reply.EnsureProtocolSuccess();
reply.EnsureMxAccessSuccess();

EnsureProtocolSuccess() raises gateway, session, worker, or command exceptions for gateway-level failures. It leaves PROTOCOL_STATUS_CODE_MXACCESS_FAILURE to EnsureMxAccessSuccess() so callers 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:

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 the effective API key, whether it was supplied through --api-key or resolved from the --api-key-env environment variable.

Galaxy Repository Browse

GalaxyRepositoryClient is a separate read-only wrapper around the GalaxyRepository gRPC service exposed by the same gateway. It shares the API key auth interceptor with MxGatewayClient and requires the metadata:read scope server-side. Use it to probe the ZB SQL connection, watch time_of_last_deploy for redeployments, and enumerate the deployed Galaxy object hierarchy plus each object's dynamic attributes.

await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
    new MxGatewayClientOptions
    {
        Endpoint = new Uri("http://localhost:5000"),
        ApiKey = apiKey,
    });

bool ok = await repository.TestConnectionAsync();
DateTime? lastDeploy = await repository.GetLastDeployTimeAsync();

IReadOnlyList<GalaxyObject> objects = await repository.DiscoverHierarchyAsync();
foreach (GalaxyObject galaxyObject in objects)
{
    Console.WriteLine($"{galaxyObject.TagName} ({galaxyObject.ContainedName})");
    foreach (GalaxyAttribute attribute in galaxyObject.Attributes)
    {
        Console.WriteLine($"  {attribute.AttributeName} -> {attribute.FullTagReference}");
    }
}

Use DiscoverHierarchyOptions to request a server-side slice without pulling the full Galaxy:

IReadOnlyList<GalaxyObject> pumps = await repository.DiscoverHierarchyAsync(
    new DiscoverHierarchyOptions
    {
        RootContainedPath = "Area1/Line3",
        TagNameGlob = "Pump_*",
        IncludeAttributes = false,
    });

The CLI exposes the same operations:

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

Watching deploy events

WatchDeployEventsAsync opens the WatchDeployEvents server-streaming RPC. The server emits a bootstrap event with the current state on subscribe, then one event per new time_of_last_deploy. Pass a lastSeenDeployTime to suppress the bootstrap when the caller already holds the current deploy time. Use the monotonic Sequence field to detect dropped events: gaps mean the per-subscriber server-side buffer overflowed and the caller should reconcile.

Streaming RPCs are not wrapped by the unary safe-read retry pipeline. The caller is responsible for reopening the stream on transient failures.

await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(options);

DateTimeOffset? lastSeen = null;
await foreach (DeployEvent evt in repository.WatchDeployEventsAsync(
    lastSeen,
    cancellationToken))
{
    Console.WriteLine(
        $"seq={evt.Sequence} objects={evt.ObjectCount} attributes={evt.AttributeCount}");
    if (evt.TimeOfLastDeployPresent && evt.TimeOfLastDeploy is not null)
    {
        lastSeen = evt.TimeOfLastDeploy.ToDateTimeOffset();
    }
}

The CLI counterpart streams events until Ctrl+C (or --max-events):

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:

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

Integration Checks

Run live checks only when a gateway and MXAccess-backed worker are available:

$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/MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json