Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd92048f4e | |||
| 964b40dcbc | |||
| bb5603b7ec | |||
| 24de7e21d9 | |||
| ee959e46e6 | |||
| 771229b39f | |||
| a7bf1ef95d | |||
| b4f5e8eb48 | |||
| 371bcb3f91 | |||
| 9582de077b | |||
| bd3096533d | |||
| 6eb9ea9105 | |||
| 555fe4c0ba | |||
| 89043cb2b6 | |||
| 1764eff1cf | |||
| fe9044115b |
@@ -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,7 +114,7 @@ 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 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.
|
||||
|
||||
|
||||
@@ -122,7 +122,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 (arguments.HasFlag("json"))
|
||||
@@ -167,6 +170,27 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -177,14 +201,7 @@ public static class MxGatewayClientCli
|
||||
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}.");
|
||||
return Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
|
||||
}
|
||||
|
||||
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -378,6 +378,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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,9 +7,19 @@ namespace MxGateway.Client;
|
||||
/// </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;
|
||||
}
|
||||
|
||||
@@ -38,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);
|
||||
|
||||
@@ -47,6 +52,11 @@ public sealed class MxGatewayClientOptions
|
||||
/// </summary>
|
||||
public TimeSpan? StreamTimeout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum size, in bytes, of a single gRPC message the client will
|
||||
/// send or receive. Applied to both the send and receive limits of the
|
||||
/// underlying channel. Defaults to 16 MiB.
|
||||
/// </summary>
|
||||
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,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>
|
||||
@@ -143,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>
|
||||
@@ -194,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>
|
||||
@@ -723,4 +726,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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,8 @@ dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint htt
|
||||
`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
|
||||
|
||||
|
||||
@@ -90,6 +90,19 @@ events may be lost. Raw protobuf messages remain available through the
|
||||
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
||||
errors preserve the raw reply.
|
||||
|
||||
`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
|
||||
|
||||
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
|
||||
|
||||
@@ -150,8 +150,8 @@ func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
|
||||
SessionId: "session-1",
|
||||
AlarmFilterPrefix: "Tank01.",
|
||||
SessionId: "session-1",
|
||||
AlarmFilterPrefix: "Tank01.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryActiveAlarms() error = %v", err)
|
||||
@@ -221,8 +221,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)},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -292,8 +292,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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"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
|
||||
@@ -42,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 {
|
||||
|
||||
@@ -2,7 +2,6 @@ package mxgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
@@ -56,39 +55,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
|
||||
}
|
||||
|
||||
@@ -239,18 +212,5 @@ func (c *GalaxyClient) Close() error {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ func TestGalaxyGetLastDeployTimeReturnsTimestampWhenPresent(t *testing.T) {
|
||||
want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC)
|
||||
fake := &fakeGalaxyServer{
|
||||
deployReply: &pb.GetLastDeployTimeReply{
|
||||
Present: true,
|
||||
TimeOfLastDeploy: timestamppb.New(want),
|
||||
Present: true,
|
||||
TimeOfLastDeploy: timestamppb.New(want),
|
||||
},
|
||||
}
|
||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||
@@ -348,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{
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -547,10 +549,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[:])
|
||||
}
|
||||
|
||||
+16
-1
@@ -74,10 +74,25 @@ 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 connect timeout
|
||||
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. The event stream uses gRPC's default auto-inbound flow
|
||||
control with a fixed 16-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.
|
||||
|
||||
## Galaxy Repository Browse
|
||||
|
||||
|
||||
+38
-11
@@ -661,33 +661,60 @@ 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);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
MxGatewayClientOptions toClientOptions() {
|
||||
return MxGatewayClientOptions.builder()
|
||||
.endpoint(endpoint)
|
||||
.apiKey(resolvedApiKey)
|
||||
.apiKey(resolvedApiKey())
|
||||
.plaintext(plaintext)
|
||||
.caCertificatePath(caFile)
|
||||
.serverNameOverride(serverNameOverride)
|
||||
.callTimeout(resolvedTimeout)
|
||||
.callTimeout(resolvedTimeout())
|
||||
.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());
|
||||
|
||||
+51
-96
@@ -1,8 +1,5 @@
|
||||
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.DeployEvent;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
|
||||
@@ -17,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;
|
||||
@@ -27,7 +22,6 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.net.ssl.SSLException;
|
||||
|
||||
/**
|
||||
* Thin wrapper around the generated {@link GalaxyRepositoryGrpc} stubs that
|
||||
@@ -78,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,7 +128,9 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
* exceptionally with {@link MxGatewayException} on failure
|
||||
*/
|
||||
public CompletableFuture<Boolean> testConnectionAsync() {
|
||||
return toCompletable(rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()))
|
||||
return MxGatewayChannels.toCompletable(
|
||||
rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()),
|
||||
"galaxy test connection")
|
||||
.thenApply(TestConnectionReply::getOk);
|
||||
}
|
||||
|
||||
@@ -165,8 +162,11 @@ 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")
|
||||
.thenApply(MxGatewayChannels.normalisingValidator(
|
||||
"galaxy get last deploy time", GalaxyRepositoryClient::mapDeployTime));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,7 +224,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;
|
||||
}
|
||||
|
||||
@@ -253,7 +254,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;
|
||||
}
|
||||
@@ -269,17 +270,31 @@ 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 the configured connect timeout 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()}.
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
if (ownedChannel != null) {
|
||||
ownedChannel.shutdown();
|
||||
if (ownedChannel == null) {
|
||||
return;
|
||||
}
|
||||
ownedChannel.shutdown();
|
||||
try {
|
||||
if (!ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
ownedChannel.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException error) {
|
||||
ownedChannel.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,86 +322,26 @@ 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) {
|
||||
DiscoverHierarchyRequest request = DiscoverHierarchyRequest.newBuilder()
|
||||
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
|
||||
.setPageToken(pageToken)
|
||||
.build();
|
||||
return toCompletable(rawFutureStub().discoverHierarchy(request)).thenCompose(reply -> {
|
||||
objects.addAll(reply.getObjectsList());
|
||||
if (reply.getNextPageToken().isBlank()) {
|
||||
return CompletableFuture.completedFuture(objects);
|
||||
}
|
||||
if (!seenPageTokens.add(reply.getNextPageToken())) {
|
||||
CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>();
|
||||
failed.completeExceptionally(new MxGatewayException(
|
||||
"galaxy discover hierarchy returned repeated page token: " + reply.getNextPageToken()));
|
||||
return failed;
|
||||
}
|
||||
return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens);
|
||||
});
|
||||
}
|
||||
|
||||
private static <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
|
||||
CompletableFuture<T> target = new CompletableFuture<>();
|
||||
Futures.addCallback(
|
||||
source,
|
||||
new FutureCallback<>() {
|
||||
@Override
|
||||
public void onSuccess(T result) {
|
||||
target.complete(result);
|
||||
return MxGatewayChannels.toCompletable(rawFutureStub().discoverHierarchy(request), "galaxy discover hierarchy")
|
||||
.thenCompose(reply -> {
|
||||
objects.addAll(reply.getObjectsList());
|
||||
if (reply.getNextPageToken().isBlank()) {
|
||||
return CompletableFuture.completedFuture(objects);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable error) {
|
||||
if (error instanceof RuntimeException runtimeException) {
|
||||
target.completeExceptionally(MxGatewayErrors.fromGrpc("galaxy async call", runtimeException));
|
||||
return;
|
||||
}
|
||||
target.completeExceptionally(error);
|
||||
if (!seenPageTokens.add(reply.getNextPageToken())) {
|
||||
CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>();
|
||||
failed.completeExceptionally(new MxGatewayException(
|
||||
"galaxy discover hierarchy returned repeated page token: "
|
||||
+ reply.getNextPageToken()));
|
||||
return failed;
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor());
|
||||
target.whenComplete((ignoredResult, ignoredError) -> {
|
||||
if (target.isCancelled()) {
|
||||
source.cancel(true);
|
||||
}
|
||||
});
|
||||
return target;
|
||||
return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -22,6 +22,18 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
* 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
|
||||
* 16-element buffer. 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
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
|
||||
import io.grpc.stub.AbstractStub;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Function;
|
||||
import javax.net.ssl.SSLException;
|
||||
|
||||
/**
|
||||
* Shared channel-builder and future-adaptor helpers used by both
|
||||
* {@link MxGatewayClient} and {@link GalaxyRepositoryClient}.
|
||||
*
|
||||
* <p>Extracted so transport construction, per-call deadlines, and the
|
||||
* {@link ListenableFuture}-to-{@link CompletableFuture} bridge live in one
|
||||
* place instead of being duplicated verbatim across the two clients.
|
||||
*/
|
||||
final class MxGatewayChannels {
|
||||
private MxGatewayChannels() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Netty managed channel from the supplied options, applying the
|
||||
* connect timeout, message-size limit, and the configured transport
|
||||
* security mode (plaintext, custom CA trust, or system trust).
|
||||
*
|
||||
* @param options the client options carrying endpoint and transport config
|
||||
* @param tlsErrorPrefix a human-readable prefix for the {@link MxGatewayException}
|
||||
* thrown when a custom CA certificate cannot be loaded
|
||||
* @return a new managed channel; the caller owns its lifecycle
|
||||
*/
|
||||
static ManagedChannel createChannel(MxGatewayClientOptions options, String tlsErrorPrefix) {
|
||||
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
|
||||
.maxInboundMessageSize(options.maxGrpcMessageBytes());
|
||||
if (!options.connectTimeout().isNegative()) {
|
||||
builder.withOption(
|
||||
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
|
||||
Math.toIntExact(options.connectTimeout().toMillis()));
|
||||
}
|
||||
if (options.plaintext()) {
|
||||
builder.usePlaintext();
|
||||
} else if (options.caCertificatePath() != null) {
|
||||
try {
|
||||
builder.sslContext(GrpcSslContexts.forClient()
|
||||
.trustManager(options.caCertificatePath().toFile())
|
||||
.build());
|
||||
} catch (SSLException | RuntimeException error) {
|
||||
// SSLException covers handshake-context failures; RuntimeException
|
||||
// (IllegalArgumentException wrapping CertificateException) covers a
|
||||
// missing or unreadable CA file. Either way callers see one typed
|
||||
// failure instead of a raw, unwrapped exception leaking out.
|
||||
throw new MxGatewayException(tlsErrorPrefix, error);
|
||||
}
|
||||
} else {
|
||||
builder.useTransportSecurity();
|
||||
}
|
||||
if (!options.serverNameOverride().isBlank()) {
|
||||
builder.overrideAuthority(options.serverNameOverride());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the configured per-call deadline to a unary stub.
|
||||
*
|
||||
* @param stub the stub to decorate
|
||||
* @param options the client options carrying the call timeout
|
||||
* @param <T> the concrete stub type
|
||||
* @return the stub with the call deadline applied, or the stub unchanged
|
||||
* when the call timeout is negative (disabled)
|
||||
*/
|
||||
static <T extends AbstractStub<T>> T withDeadline(T stub, MxGatewayClientOptions options) {
|
||||
if (options.callTimeout().isNegative()) {
|
||||
return stub;
|
||||
}
|
||||
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the configured streaming deadline to a streaming stub.
|
||||
*
|
||||
* @param stub the stub to decorate
|
||||
* @param options the client options carrying the stream timeout
|
||||
* @param <T> the concrete stub type
|
||||
* @return the stub with the stream deadline applied, or the stub unchanged
|
||||
* when the stream timeout is unset or negative (disabled)
|
||||
*/
|
||||
static <T extends AbstractStub<T>> T withStreamDeadline(T stub, MxGatewayClientOptions options) {
|
||||
if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
|
||||
return stub;
|
||||
}
|
||||
return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges a Guava {@link ListenableFuture} to a {@link CompletableFuture},
|
||||
* normalising any failure through {@link MxGatewayErrors#fromGrpc} so the
|
||||
* async error surface matches the synchronous methods. Cancelling the
|
||||
* returned future cancels the source RPC.
|
||||
*
|
||||
* @param source the gRPC future-stub result
|
||||
* @param operation the operation name used in normalised error messages
|
||||
* @param <T> the reply type
|
||||
* @return a completable future mirroring the source
|
||||
*/
|
||||
static <T> CompletableFuture<T> toCompletable(ListenableFuture<T> source, String operation) {
|
||||
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(operation, runtimeException));
|
||||
return;
|
||||
}
|
||||
target.completeExceptionally(error);
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor());
|
||||
target.whenComplete((ignoredResult, ignoredError) -> {
|
||||
if (target.isCancelled()) {
|
||||
source.cancel(true);
|
||||
}
|
||||
});
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a reply-validating function for use inside {@code thenApply} so
|
||||
* any non-{@link MxGatewayException} {@link RuntimeException} it raises is
|
||||
* routed through {@link MxGatewayErrors#fromGrpc}. This keeps the async
|
||||
* error surface consistent with the synchronous methods, which normalise
|
||||
* failures with a {@code try/catch}.
|
||||
*
|
||||
* @param operation the operation name used in normalised error messages
|
||||
* @param validator the validating/transforming function applied to the reply
|
||||
* @param <T> the reply type
|
||||
* @param <R> the result type
|
||||
* @return a function suitable for {@link CompletableFuture#thenApply}
|
||||
*/
|
||||
static <T, R> Function<T, R> normalisingValidator(String operation, Function<T, R> validator) {
|
||||
return reply -> {
|
||||
try {
|
||||
return validator.apply(reply);
|
||||
} catch (MxGatewayException error) {
|
||||
throw error;
|
||||
} catch (RuntimeException error) {
|
||||
throw MxGatewayErrors.fromGrpc(operation, error);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+47
-94
@@ -1,19 +1,13 @@
|
||||
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 com.google.protobuf.Duration;
|
||||
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.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.net.ssl.SSLException;
|
||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||
@@ -79,7 +73,8 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* @return a connected client
|
||||
*/
|
||||
public static MxGatewayClient connect(MxGatewayClientOptions options) {
|
||||
return new MxGatewayClient(createChannel(options), options);
|
||||
return new MxGatewayClient(
|
||||
MxGatewayChannels.createChannel(options, "failed to configure gateway TLS"), options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +83,7 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* @return the blocking stub
|
||||
*/
|
||||
public MxAccessGatewayGrpc.MxAccessGatewayBlockingStub rawBlockingStub() {
|
||||
return withDeadline(blockingStub);
|
||||
return MxGatewayChannels.withDeadline(blockingStub, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +92,7 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* @return the future stub
|
||||
*/
|
||||
public MxAccessGatewayGrpc.MxAccessGatewayFutureStub rawFutureStub() {
|
||||
return withDeadline(futureStub);
|
||||
return MxGatewayChannels.withDeadline(futureStub, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,12 +181,13 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* with {@link MxGatewayException} on failure
|
||||
*/
|
||||
public CompletableFuture<OpenSessionReply> openSessionAsync(OpenSessionRequest request) {
|
||||
CompletableFuture<OpenSessionReply> future = toCompletable(rawFutureStub().openSession(request));
|
||||
return future.thenApply(reply -> {
|
||||
CompletableFuture<OpenSessionReply> future =
|
||||
MxGatewayChannels.toCompletable(rawFutureStub().openSession(request), "open session");
|
||||
return future.thenApply(MxGatewayChannels.normalisingValidator("open session", reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("open session", reply.getProtocolStatus(), null);
|
||||
ensureGatewayProtocolCompatible(reply);
|
||||
return reply;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,12 +222,13 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* on failure
|
||||
*/
|
||||
public CompletableFuture<MxCommandReply> invokeAsync(MxCommandRequest request) {
|
||||
CompletableFuture<MxCommandReply> future = toCompletable(rawFutureStub().invoke(request));
|
||||
return future.thenApply(reply -> {
|
||||
CompletableFuture<MxCommandReply> future =
|
||||
MxGatewayChannels.toCompletable(rawFutureStub().invoke(request), "invoke");
|
||||
return future.thenApply(MxGatewayChannels.normalisingValidator("invoke", reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("invoke", reply.getProtocolStatus(), reply);
|
||||
MxGatewayErrors.ensureMxAccessSuccess("invoke", reply);
|
||||
return reply;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,7 +261,7 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
*/
|
||||
public MxEventStream streamEvents(StreamEventsRequest request) {
|
||||
MxEventStream stream = new MxEventStream(16);
|
||||
withStreamDeadline(rawAsyncStub()).streamEvents(request, stream.observer());
|
||||
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options).streamEvents(request, stream.observer());
|
||||
return stream;
|
||||
}
|
||||
|
||||
@@ -279,15 +276,17 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
public MxGatewayEventSubscription streamEventsAsync(
|
||||
StreamEventsRequest request, StreamObserver<MxEvent> observer) {
|
||||
MxGatewayEventSubscription subscription = new MxGatewayEventSubscription();
|
||||
withStreamDeadline(rawAsyncStub()).streamEvents(request, subscription.wrap(observer));
|
||||
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
||||
.streamEvents(request, subscription.wrap(observer));
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledges an active MXAccess alarm condition through the gateway.
|
||||
*
|
||||
* <p>The gateway authenticates the request against the API key's
|
||||
* {@code invoke:alarm-ack} scope and forwards the acknowledge to the
|
||||
* <p>The gateway authorizes this request against the API key's
|
||||
* {@code admin} scope (the gateway scope resolver maps alarm RPCs to the
|
||||
* default {@code admin} scope) and forwards the acknowledge to the
|
||||
* worker's MXAccess session; the resulting native MxStatus is returned
|
||||
* in the reply. Acks are idempotent at the MxAccess layer.
|
||||
*
|
||||
@@ -316,11 +315,12 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* with {@link MxGatewayException} on failure
|
||||
*/
|
||||
public CompletableFuture<AcknowledgeAlarmReply> acknowledgeAlarmAsync(AcknowledgeAlarmRequest request) {
|
||||
CompletableFuture<AcknowledgeAlarmReply> future = toCompletable(rawFutureStub().acknowledgeAlarm(request));
|
||||
return future.thenApply(reply -> {
|
||||
CompletableFuture<AcknowledgeAlarmReply> future =
|
||||
MxGatewayChannels.toCompletable(rawFutureStub().acknowledgeAlarm(request), "acknowledge alarm");
|
||||
return future.thenApply(MxGatewayChannels.normalisingValidator("acknowledge alarm", reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,14 +336,36 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
public MxGatewayActiveAlarmsSubscription queryActiveAlarms(
|
||||
QueryActiveAlarmsRequest request, StreamObserver<ActiveAlarmSnapshot> observer) {
|
||||
MxGatewayActiveAlarmsSubscription subscription = new MxGatewayActiveAlarmsSubscription();
|
||||
withStreamDeadline(rawAsyncStub()).queryActiveAlarms(request, subscription.wrap(observer));
|
||||
MxGatewayChannels.withStreamDeadline(rawAsyncStub(), options)
|
||||
.queryActiveAlarms(request, subscription.wrap(observer));
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 the configured connect timeout 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()}.
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
if (ownedChannel != null) {
|
||||
ownedChannel.shutdown();
|
||||
if (ownedChannel == null) {
|
||||
return;
|
||||
}
|
||||
ownedChannel.shutdown();
|
||||
try {
|
||||
if (!ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
ownedChannel.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException error) {
|
||||
ownedChannel.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,75 +385,6 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
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 gateway 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 <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);
|
||||
}
|
||||
|
||||
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("async call", runtimeException));
|
||||
return;
|
||||
}
|
||||
target.completeExceptionally(error);
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor());
|
||||
target.whenComplete((ignoredResult, ignoredError) -> {
|
||||
if (target.isCancelled()) {
|
||||
source.cancel(true);
|
||||
}
|
||||
});
|
||||
return target;
|
||||
}
|
||||
|
||||
static ProtocolStatusCode okStatusCode() {
|
||||
return ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK;
|
||||
}
|
||||
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
package com.dohertylan.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.Server;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.inprocess.InProcessChannelBuilder;
|
||||
import io.grpc.inprocess.InProcessServerBuilder;
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
import io.grpc.stub.ClientResponseObserver;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Regression tests for the Low-severity Client.Java code-review findings
|
||||
* (Client.Java-006 through Client.Java-012). Covers the alarm RPC surface,
|
||||
* async streaming/subscription cancellation, queue overflow, and TLS-config
|
||||
* construction that Client.Java-007 reports as untested.
|
||||
*/
|
||||
final class MxGatewayLowFindingsTests {
|
||||
|
||||
// --- Client.Java-007: AcknowledgeAlarm RPC coverage ---
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmReturnsReplyAndSendsAuthMetadata() throws Exception {
|
||||
AtomicReference<String> authorization = new AtomicReference<>();
|
||||
AtomicReference<AcknowledgeAlarmRequest> seen = new AtomicReference<>();
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
||||
seen.set(request);
|
||||
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setProtocolStatus(ok())
|
||||
.setDiagnosticMessage("acked")
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service, "mxgw_keyid_secret", authorization)) {
|
||||
AcknowledgeAlarmReply reply = harness.client().acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
|
||||
.setSessionId("s-1")
|
||||
.setAlarmFullReference("Area1.Pump.PV.HiHi")
|
||||
.setComment("operator note")
|
||||
.build());
|
||||
assertEquals("acked", reply.getDiagnosticMessage());
|
||||
assertEquals("Area1.Pump.PV.HiHi", seen.get().getAlarmFullReference());
|
||||
assertEquals("Bearer mxgw_keyid_secret", authorization.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmThrowsTypedExceptionOnProtocolFailure() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
||||
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND))
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
assertThrows(
|
||||
MxGatewayException.class,
|
||||
() -> harness.client().acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
|
||||
.setSessionId("missing")
|
||||
.build()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmAsyncCompletesWithReply() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
||||
responseObserver.onNext(AcknowledgeAlarmReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setProtocolStatus(ok())
|
||||
.setDiagnosticMessage("async-acked")
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
CompletableFuture<AcknowledgeAlarmReply> future = harness.client()
|
||||
.acknowledgeAlarmAsync(AcknowledgeAlarmRequest.newBuilder().setSessionId("s-2").build());
|
||||
assertEquals("async-acked", future.get(5, TimeUnit.SECONDS).getDiagnosticMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmAsyncFailsExceptionallyWithTypedException() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
AcknowledgeAlarmRequest request, StreamObserver<AcknowledgeAlarmReply> responseObserver) {
|
||||
responseObserver.onError(Status.UNAVAILABLE.withDescription("worker down").asRuntimeException());
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
CompletableFuture<AcknowledgeAlarmReply> future = harness.client()
|
||||
.acknowledgeAlarmAsync(AcknowledgeAlarmRequest.newBuilder().setSessionId("s-3").build());
|
||||
ExecutionException error = assertThrows(
|
||||
ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS));
|
||||
assertTrue(error.getCause() instanceof MxGatewayException, () -> String.valueOf(error.getCause()));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Java-007: QueryActiveAlarms RPC + subscription coverage ---
|
||||
|
||||
@Test
|
||||
void queryActiveAlarmsDeliversSnapshotsToObserver() throws Exception {
|
||||
ActiveAlarmSnapshot snapshot = ActiveAlarmSnapshot.newBuilder()
|
||||
.setAlarmFullReference("Area1.Tank.Level.Hi")
|
||||
.setSeverity(800)
|
||||
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
||||
.build();
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void queryActiveAlarms(
|
||||
QueryActiveAlarmsRequest request, StreamObserver<ActiveAlarmSnapshot> responseObserver) {
|
||||
responseObserver.onNext(snapshot);
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
List<ActiveAlarmSnapshot> received = new ArrayList<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
harness.client().queryActiveAlarms(
|
||||
QueryActiveAlarmsRequest.newBuilder().setSessionId("s-4").build(),
|
||||
new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(ActiveAlarmSnapshot value) {
|
||||
received.add(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
done.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS), "stream should complete");
|
||||
assertEquals(1, received.size());
|
||||
assertEquals("Area1.Tank.Level.Hi", received.get(0).getAlarmFullReference());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeAlarmsSubscriptionCancelBeforeBeforeStartCancelsStream() {
|
||||
MxGatewayActiveAlarmsSubscription subscription = new MxGatewayActiveAlarmsSubscription();
|
||||
ClientResponseObserver<QueryActiveAlarmsRequest, ActiveAlarmSnapshot> observer =
|
||||
subscription.wrap(new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(ActiveAlarmSnapshot value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
});
|
||||
RecordingActiveAlarmsRequestStream requestStream = new RecordingActiveAlarmsRequestStream();
|
||||
|
||||
subscription.cancel();
|
||||
observer.beforeStart(requestStream);
|
||||
|
||||
assertTrue(requestStream.cancelled);
|
||||
assertEquals("client cancelled active-alarms query", requestStream.cancelMessage);
|
||||
}
|
||||
|
||||
// --- Client.Java-007: async streamEvents + subscription cancellation ---
|
||||
|
||||
@Test
|
||||
void streamEventsAsyncDeliversEventsToObserver() throws Exception {
|
||||
MxEvent event = MxEvent.newBuilder().setWorkerSequence(7).build();
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void streamEvents(StreamEventsRequest request, StreamObserver<MxEvent> responseObserver) {
|
||||
responseObserver.onNext(event);
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (Harness harness = Harness.start(service)) {
|
||||
List<MxEvent> received = new ArrayList<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
harness.client().streamEventsAsync(
|
||||
StreamEventsRequest.newBuilder().setSessionId("s-5").build(),
|
||||
new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(MxEvent value) {
|
||||
received.add(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
done.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS), "stream should complete");
|
||||
assertEquals(1, received.size());
|
||||
assertEquals(7, received.get(0).getWorkerSequence());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventSubscriptionCancelBeforeBeforeStartCancelsStream() {
|
||||
MxGatewayEventSubscription subscription = new MxGatewayEventSubscription();
|
||||
ClientResponseObserver<StreamEventsRequest, MxEvent> observer =
|
||||
subscription.wrap(new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(MxEvent value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
});
|
||||
RecordingEventsRequestStream requestStream = new RecordingEventsRequestStream();
|
||||
|
||||
subscription.cancel();
|
||||
observer.beforeStart(requestStream);
|
||||
|
||||
assertTrue(requestStream.cancelled);
|
||||
assertEquals("client cancelled event stream", requestStream.cancelMessage);
|
||||
}
|
||||
|
||||
// --- Client.Java-007 / Client.Java-011: MxEventStream queue overflow ---
|
||||
|
||||
@Test
|
||||
void eventStreamQueueOverflowSurfacesExceptionFromNext() {
|
||||
MxEventStream stream = new MxEventStream(2);
|
||||
ClientResponseObserver<StreamEventsRequest, MxEvent> observer = stream.observer();
|
||||
RecordingEventsRequestStream requestStream = new RecordingEventsRequestStream();
|
||||
observer.beforeStart(requestStream);
|
||||
|
||||
// Push far more events than the capacity-2 buffer can hold without draining.
|
||||
for (int i = 0; i < 16; i++) {
|
||||
observer.onNext(MxEvent.newBuilder().setWorkerSequence(i).build());
|
||||
}
|
||||
|
||||
// Overflow must cancel the gRPC call and surface as MxGatewayException.
|
||||
assertTrue(requestStream.cancelled, "overflow should cancel the underlying call");
|
||||
MxGatewayException error = assertThrows(MxGatewayException.class, () -> {
|
||||
while (stream.hasNext()) {
|
||||
stream.next();
|
||||
}
|
||||
});
|
||||
assertTrue(error.getMessage().contains("overflow"), error::getMessage);
|
||||
}
|
||||
|
||||
// --- Client.Java-007: TLS channel construction ---
|
||||
|
||||
@Test
|
||||
void connectWithMissingCaCertificateThrowsTypedTlsException() {
|
||||
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||
.endpoint("localhost:5001")
|
||||
.apiKey("mxgw_id_secret")
|
||||
.plaintext(false)
|
||||
.caCertificatePath(Path.of("does-not-exist-" + UUID.randomUUID() + ".pem"))
|
||||
.build();
|
||||
|
||||
MxGatewayException error = assertThrows(MxGatewayException.class, () -> MxGatewayClient.connect(options));
|
||||
assertTrue(error.getMessage().contains("TLS"), error::getMessage);
|
||||
|
||||
MxGatewayException galaxyError =
|
||||
assertThrows(MxGatewayException.class, () -> GalaxyRepositoryClient.connect(options));
|
||||
assertTrue(galaxyError.getMessage().contains("TLS"), galaxyError::getMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectWithSystemTrustBuildsTlsChannelWithoutError() {
|
||||
// No CA path and plaintext=false exercises the useTransportSecurity() branch.
|
||||
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||
.endpoint("localhost:5001")
|
||||
.apiKey("mxgw_id_secret")
|
||||
.plaintext(false)
|
||||
.build();
|
||||
|
||||
try (MxGatewayClient client = MxGatewayClient.connect(options)) {
|
||||
assertNotNull(client);
|
||||
}
|
||||
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
|
||||
assertNotNull(galaxy);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client.Java-008: async error surface is normalised ---
|
||||
|
||||
@Test
|
||||
void openSessionAsyncNormalisesNonGatewayRuntimeExceptionFromValidator() {
|
||||
// ensureGatewayProtocolCompatible already throws MxGatewayException; this verifies
|
||||
// the normalisingValidator wrapper routes a stray RuntimeException through fromGrpc.
|
||||
CompletableFuture<String> source = new CompletableFuture<>();
|
||||
CompletableFuture<String> wrapped =
|
||||
source.thenApply(MxGatewayChannels.normalisingValidator("open session", reply -> {
|
||||
throw new IllegalStateException("malformed reply");
|
||||
}));
|
||||
source.complete("payload");
|
||||
|
||||
CompletionException error = assertThrows(CompletionException.class, wrapped::join);
|
||||
assertTrue(error.getCause() instanceof MxGatewayException, () -> String.valueOf(error.getCause()));
|
||||
}
|
||||
|
||||
private static ProtocolStatus ok() {
|
||||
return ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static class TestService extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
|
||||
}
|
||||
|
||||
private record Harness(Server server, ManagedChannel channel, MxGatewayClient client) implements AutoCloseable {
|
||||
static Harness start(MxAccessGatewayGrpc.MxAccessGatewayImplBase service) throws Exception {
|
||||
return start(service, "", new AtomicReference<>());
|
||||
}
|
||||
|
||||
static Harness start(
|
||||
MxAccessGatewayGrpc.MxAccessGatewayImplBase service,
|
||||
String apiKey,
|
||||
AtomicReference<String> authorization)
|
||||
throws Exception {
|
||||
String name = "mxgw-low-" + UUID.randomUUID();
|
||||
io.grpc.ServerInterceptor interceptor = new io.grpc.ServerInterceptor() {
|
||||
@Override
|
||||
public <ReqT, RespT> io.grpc.ServerCall.Listener<ReqT> interceptCall(
|
||||
io.grpc.ServerCall<ReqT, RespT> call,
|
||||
io.grpc.Metadata headers,
|
||||
io.grpc.ServerCallHandler<ReqT, RespT> next) {
|
||||
authorization.set(headers.get(MxGatewayAuthInterceptor.AUTHORIZATION_HEADER));
|
||||
return next.startCall(call, headers);
|
||||
}
|
||||
};
|
||||
Server server = InProcessServerBuilder.forName(name)
|
||||
.directExecutor()
|
||||
.addService(io.grpc.ServerInterceptors.intercept(service, interceptor))
|
||||
.build()
|
||||
.start();
|
||||
ManagedChannel channel = InProcessChannelBuilder.forName(name).directExecutor().build();
|
||||
MxGatewayClient client = new MxGatewayClient(
|
||||
channel,
|
||||
MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process")
|
||||
.apiKey(apiKey)
|
||||
.plaintext(true)
|
||||
.callTimeout(Duration.ofSeconds(5))
|
||||
.streamTimeout(Duration.ofSeconds(5))
|
||||
.build());
|
||||
return new Harness(server, channel, client);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
channel.shutdownNow();
|
||||
server.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingEventsRequestStream
|
||||
extends ClientCallStreamObserver<StreamEventsRequest> {
|
||||
private boolean cancelled;
|
||||
private String cancelMessage;
|
||||
|
||||
@Override
|
||||
public void cancel(String message, Throwable cause) {
|
||||
cancelled = true;
|
||||
cancelMessage = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnReadyHandler(Runnable onReadyHandler) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void request(int count) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMessageCompression(boolean enable) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAutoInboundFlowControl() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(StreamEventsRequest value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingActiveAlarmsRequestStream
|
||||
extends ClientCallStreamObserver<QueryActiveAlarmsRequest> {
|
||||
private boolean cancelled;
|
||||
private String cancelMessage;
|
||||
|
||||
@Override
|
||||
public void cancel(String message, Throwable cause) {
|
||||
cancelled = true;
|
||||
cancelMessage = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnReadyHandler(Runnable onReadyHandler) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void request(int count) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMessageCompression(boolean enable) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAutoInboundFlowControl() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(QueryActiveAlarmsRequest value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
@@ -139,6 +139,68 @@ public final class MxAccessGatewayGrpc {
|
||||
return getStreamEventsMethod;
|
||||
}
|
||||
|
||||
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> getAcknowledgeAlarmMethod;
|
||||
|
||||
@io.grpc.stub.annotations.RpcMethod(
|
||||
fullMethodName = SERVICE_NAME + '/' + "AcknowledgeAlarm",
|
||||
requestType = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.class,
|
||||
responseType = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.class,
|
||||
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> getAcknowledgeAlarmMethod() {
|
||||
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest, mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> getAcknowledgeAlarmMethod;
|
||||
if ((getAcknowledgeAlarmMethod = MxAccessGatewayGrpc.getAcknowledgeAlarmMethod) == null) {
|
||||
synchronized (MxAccessGatewayGrpc.class) {
|
||||
if ((getAcknowledgeAlarmMethod = MxAccessGatewayGrpc.getAcknowledgeAlarmMethod) == null) {
|
||||
MxAccessGatewayGrpc.getAcknowledgeAlarmMethod = getAcknowledgeAlarmMethod =
|
||||
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest, mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>newBuilder()
|
||||
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "AcknowledgeAlarm"))
|
||||
.setSampledToLocalTracing(true)
|
||||
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.getDefaultInstance()))
|
||||
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.getDefaultInstance()))
|
||||
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("AcknowledgeAlarm"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return getAcknowledgeAlarmMethod;
|
||||
}
|
||||
|
||||
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod;
|
||||
|
||||
@io.grpc.stub.annotations.RpcMethod(
|
||||
fullMethodName = SERVICE_NAME + '/' + "QueryActiveAlarms",
|
||||
requestType = mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest.class,
|
||||
responseType = mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot.class,
|
||||
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod() {
|
||||
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod;
|
||||
if ((getQueryActiveAlarmsMethod = MxAccessGatewayGrpc.getQueryActiveAlarmsMethod) == null) {
|
||||
synchronized (MxAccessGatewayGrpc.class) {
|
||||
if ((getQueryActiveAlarmsMethod = MxAccessGatewayGrpc.getQueryActiveAlarmsMethod) == null) {
|
||||
MxAccessGatewayGrpc.getQueryActiveAlarmsMethod = getQueryActiveAlarmsMethod =
|
||||
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>newBuilder()
|
||||
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "QueryActiveAlarms"))
|
||||
.setSampledToLocalTracing(true)
|
||||
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest.getDefaultInstance()))
|
||||
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot.getDefaultInstance()))
|
||||
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("QueryActiveAlarms"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return getQueryActiveAlarmsMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new async stub that supports all call types for the service
|
||||
*/
|
||||
@@ -232,6 +294,20 @@ public final class MxAccessGatewayGrpc {
|
||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent> responseObserver) {
|
||||
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamEventsMethod(), responseObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
default void acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request,
|
||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> responseObserver) {
|
||||
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getAcknowledgeAlarmMethod(), responseObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
default void queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request,
|
||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> responseObserver) {
|
||||
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getQueryActiveAlarmsMethod(), responseObserver);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,6 +374,22 @@ public final class MxAccessGatewayGrpc {
|
||||
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
||||
getChannel().newCall(getStreamEventsMethod(), getCallOptions()), request, responseObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public void acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request,
|
||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> responseObserver) {
|
||||
io.grpc.stub.ClientCalls.asyncUnaryCall(
|
||||
getChannel().newCall(getAcknowledgeAlarmMethod(), getCallOptions()), request, responseObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public void queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request,
|
||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> responseObserver) {
|
||||
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
||||
getChannel().newCall(getQueryActiveAlarmsMethod(), getCallOptions()), request, responseObserver);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,6 +440,22 @@ public final class MxAccessGatewayGrpc {
|
||||
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
||||
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request) throws io.grpc.StatusException {
|
||||
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
|
||||
getChannel(), getAcknowledgeAlarmMethod(), getCallOptions(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
|
||||
public io.grpc.stub.BlockingClientCall<?, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>
|
||||
queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request) {
|
||||
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
||||
getChannel(), getQueryActiveAlarmsMethod(), getCallOptions(), request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -397,6 +505,21 @@ public final class MxAccessGatewayGrpc {
|
||||
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
||||
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request) {
|
||||
return io.grpc.stub.ClientCalls.blockingUnaryCall(
|
||||
getChannel(), getAcknowledgeAlarmMethod(), getCallOptions(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public java.util.Iterator<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> queryActiveAlarms(
|
||||
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request) {
|
||||
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
||||
getChannel(), getQueryActiveAlarmsMethod(), getCallOptions(), request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -441,12 +564,22 @@ public final class MxAccessGatewayGrpc {
|
||||
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||
getChannel().newCall(getInvokeMethod(), getCallOptions()), request);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public com.google.common.util.concurrent.ListenableFuture<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> acknowledgeAlarm(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request) {
|
||||
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||
getChannel().newCall(getAcknowledgeAlarmMethod(), getCallOptions()), request);
|
||||
}
|
||||
}
|
||||
|
||||
private static final int METHODID_OPEN_SESSION = 0;
|
||||
private static final int METHODID_CLOSE_SESSION = 1;
|
||||
private static final int METHODID_INVOKE = 2;
|
||||
private static final int METHODID_STREAM_EVENTS = 3;
|
||||
private static final int METHODID_ACKNOWLEDGE_ALARM = 4;
|
||||
private static final int METHODID_QUERY_ACTIVE_ALARMS = 5;
|
||||
|
||||
private static final class MethodHandlers<Req, Resp> implements
|
||||
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
||||
@@ -481,6 +614,14 @@ public final class MxAccessGatewayGrpc {
|
||||
serviceImpl.streamEvents((mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest) request,
|
||||
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent>) responseObserver);
|
||||
break;
|
||||
case METHODID_ACKNOWLEDGE_ALARM:
|
||||
serviceImpl.acknowledgeAlarm((mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest) request,
|
||||
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>) responseObserver);
|
||||
break;
|
||||
case METHODID_QUERY_ACTIVE_ALARMS:
|
||||
serviceImpl.queryActiveAlarms((mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest) request,
|
||||
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>) responseObserver);
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
@@ -527,6 +668,20 @@ public final class MxAccessGatewayGrpc {
|
||||
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.MxEvent>(
|
||||
service, METHODID_STREAM_EVENTS)))
|
||||
.addMethod(
|
||||
getAcknowledgeAlarmMethod(),
|
||||
io.grpc.stub.ServerCalls.asyncUnaryCall(
|
||||
new MethodHandlers<
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>(
|
||||
service, METHODID_ACKNOWLEDGE_ALARM)))
|
||||
.addMethod(
|
||||
getQueryActiveAlarmsMethod(),
|
||||
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
|
||||
new MethodHandlers<
|
||||
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
|
||||
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>(
|
||||
service, METHODID_QUERY_ACTIVE_ALARMS)))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -579,6 +734,8 @@ public final class MxAccessGatewayGrpc {
|
||||
.addMethod(getCloseSessionMethod())
|
||||
.addMethod(getInvokeMethod())
|
||||
.addMethod(getStreamEventsMethod())
|
||||
.addMethod(getAcknowledgeAlarmMethod())
|
||||
.addMethod(getQueryActiveAlarmsMethod())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+62
-62
@@ -1750,7 +1750,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>.google.protobuf.Timestamp time_of_last_deploy = 2;</code>
|
||||
*/
|
||||
private com.google.protobuf.SingleFieldBuilder<
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
internalGetTimeOfLastDeployFieldBuilder() {
|
||||
if (timeOfLastDeployBuilder_ == null) {
|
||||
timeOfLastDeployBuilder_ = new com.google.protobuf.SingleFieldBuilder<
|
||||
@@ -2175,7 +2175,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
pageToken_ = s;
|
||||
@@ -2195,7 +2195,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getPageTokenBytes() {
|
||||
java.lang.Object ref = pageToken_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
pageToken_ = b;
|
||||
@@ -2246,7 +2246,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
if (rootCase_ == 4) {
|
||||
@@ -2266,7 +2266,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
ref = root_;
|
||||
}
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
if (rootCase_ == 4) {
|
||||
@@ -2298,7 +2298,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
if (rootCase_ == 5) {
|
||||
@@ -2318,7 +2318,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
ref = root_;
|
||||
}
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
if (rootCase_ == 5) {
|
||||
@@ -2483,7 +2483,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
tagNameGlob_ = s;
|
||||
@@ -2503,7 +2503,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getTagNameGlobBytes() {
|
||||
java.lang.Object ref = tagNameGlob_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
tagNameGlob_ = b;
|
||||
@@ -3328,7 +3328,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getPageTokenBytes() {
|
||||
java.lang.Object ref = pageToken_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
pageToken_ = b;
|
||||
@@ -3471,7 +3471,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
ref = root_;
|
||||
}
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
if (rootCase_ == 4) {
|
||||
@@ -3564,7 +3564,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
ref = root_;
|
||||
}
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
if (rootCase_ == 5) {
|
||||
@@ -3768,7 +3768,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>.google.protobuf.Int32Value max_depth = 6;</code>
|
||||
*/
|
||||
private com.google.protobuf.SingleFieldBuilder<
|
||||
com.google.protobuf.Int32Value, com.google.protobuf.Int32Value.Builder, com.google.protobuf.Int32ValueOrBuilder>
|
||||
com.google.protobuf.Int32Value, com.google.protobuf.Int32Value.Builder, com.google.protobuf.Int32ValueOrBuilder>
|
||||
internalGetMaxDepthFieldBuilder() {
|
||||
if (maxDepthBuilder_ == null) {
|
||||
maxDepthBuilder_ = new com.google.protobuf.SingleFieldBuilder<
|
||||
@@ -4073,7 +4073,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getTagNameGlobBytes() {
|
||||
java.lang.Object ref = tagNameGlob_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
tagNameGlob_ = b;
|
||||
@@ -4334,7 +4334,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
*/
|
||||
java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject>
|
||||
java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject>
|
||||
getObjectsList();
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
@@ -4347,7 +4347,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
*/
|
||||
java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
getObjectsOrBuilderList();
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
@@ -4438,7 +4438,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
*/
|
||||
@java.lang.Override
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
getObjectsOrBuilderList() {
|
||||
return objects_;
|
||||
}
|
||||
@@ -4482,7 +4482,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
nextPageToken_ = s;
|
||||
@@ -4502,7 +4502,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getNextPageTokenBytes() {
|
||||
java.lang.Object ref = nextPageToken_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
nextPageToken_ = b;
|
||||
@@ -4834,7 +4834,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
objectsBuilder_ = null;
|
||||
objects_ = other.objects_;
|
||||
bitField0_ = (bitField0_ & ~0x00000001);
|
||||
objectsBuilder_ =
|
||||
objectsBuilder_ =
|
||||
com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
|
||||
internalGetObjectsFieldBuilder() : null;
|
||||
} else {
|
||||
@@ -5111,7 +5111,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
*/
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
getObjectsOrBuilderList() {
|
||||
if (objectsBuilder_ != null) {
|
||||
return objectsBuilder_.getMessageOrBuilderList();
|
||||
@@ -5137,12 +5137,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyObject objects = 1;</code>
|
||||
*/
|
||||
public java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject.Builder>
|
||||
public java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject.Builder>
|
||||
getObjectsBuilderList() {
|
||||
return internalGetObjectsFieldBuilder().getBuilderList();
|
||||
}
|
||||
private com.google.protobuf.RepeatedFieldBuilder<
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObjectOrBuilder>
|
||||
internalGetObjectsFieldBuilder() {
|
||||
if (objectsBuilder_ == null) {
|
||||
objectsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
|
||||
@@ -5189,7 +5189,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getNextPageTokenBytes() {
|
||||
java.lang.Object ref = nextPageToken_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
nextPageToken_ = b;
|
||||
@@ -5924,7 +5924,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>.google.protobuf.Timestamp last_seen_deploy_time = 1;</code>
|
||||
*/
|
||||
private com.google.protobuf.SingleFieldBuilder<
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
internalGetLastSeenDeployTimeFieldBuilder() {
|
||||
if (lastSeenDeployTimeBuilder_ == null) {
|
||||
lastSeenDeployTimeBuilder_ = new com.google.protobuf.SingleFieldBuilder<
|
||||
@@ -6871,7 +6871,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>.google.protobuf.Timestamp observed_at = 2;</code>
|
||||
*/
|
||||
private com.google.protobuf.SingleFieldBuilder<
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
internalGetObservedAtFieldBuilder() {
|
||||
if (observedAtBuilder_ == null) {
|
||||
observedAtBuilder_ = new com.google.protobuf.SingleFieldBuilder<
|
||||
@@ -7028,7 +7028,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>.google.protobuf.Timestamp time_of_last_deploy = 3;</code>
|
||||
*/
|
||||
private com.google.protobuf.SingleFieldBuilder<
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
com.google.protobuf.Timestamp, com.google.protobuf.Timestamp.Builder, com.google.protobuf.TimestampOrBuilder>
|
||||
internalGetTimeOfLastDeployFieldBuilder() {
|
||||
if (timeOfLastDeployBuilder_ == null) {
|
||||
timeOfLastDeployBuilder_ = new com.google.protobuf.SingleFieldBuilder<
|
||||
@@ -7286,7 +7286,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
*/
|
||||
java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute>
|
||||
java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute>
|
||||
getAttributesList();
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
@@ -7299,7 +7299,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
*/
|
||||
java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
getAttributesOrBuilderList();
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
@@ -7374,7 +7374,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
tagName_ = s;
|
||||
@@ -7390,7 +7390,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getTagNameBytes() {
|
||||
java.lang.Object ref = tagName_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
tagName_ = b;
|
||||
@@ -7413,7 +7413,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
containedName_ = s;
|
||||
@@ -7429,7 +7429,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getContainedNameBytes() {
|
||||
java.lang.Object ref = containedName_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
containedName_ = b;
|
||||
@@ -7452,7 +7452,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
browseName_ = s;
|
||||
@@ -7468,7 +7468,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getBrowseNameBytes() {
|
||||
java.lang.Object ref = browseName_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
browseName_ = b;
|
||||
@@ -7573,7 +7573,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
*/
|
||||
@java.lang.Override
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
getAttributesOrBuilderList() {
|
||||
return attributes_;
|
||||
}
|
||||
@@ -8059,7 +8059,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
attributesBuilder_ = null;
|
||||
attributes_ = other.attributes_;
|
||||
bitField0_ = (bitField0_ & ~0x00000200);
|
||||
attributesBuilder_ =
|
||||
attributesBuilder_ =
|
||||
com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ?
|
||||
internalGetAttributesFieldBuilder() : null;
|
||||
} else {
|
||||
@@ -8226,7 +8226,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getTagNameBytes() {
|
||||
java.lang.Object ref = tagName_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
tagName_ = b;
|
||||
@@ -8298,7 +8298,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getContainedNameBytes() {
|
||||
java.lang.Object ref = containedName_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
containedName_ = b;
|
||||
@@ -8370,7 +8370,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getBrowseNameBytes() {
|
||||
java.lang.Object ref = browseName_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
browseName_ = b;
|
||||
@@ -8851,7 +8851,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
*/
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
public java.util.List<? extends galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
getAttributesOrBuilderList() {
|
||||
if (attributesBuilder_ != null) {
|
||||
return attributesBuilder_.getMessageOrBuilderList();
|
||||
@@ -8877,12 +8877,12 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
/**
|
||||
* <code>repeated .galaxy_repository.v1.GalaxyAttribute attributes = 10;</code>
|
||||
*/
|
||||
public java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute.Builder>
|
||||
public java.util.List<galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute.Builder>
|
||||
getAttributesBuilderList() {
|
||||
return internalGetAttributesFieldBuilder().getBuilderList();
|
||||
}
|
||||
private com.google.protobuf.RepeatedFieldBuilder<
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute.Builder, galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttributeOrBuilder>
|
||||
internalGetAttributesFieldBuilder() {
|
||||
if (attributesBuilder_ == null) {
|
||||
attributesBuilder_ = new com.google.protobuf.RepeatedFieldBuilder<
|
||||
@@ -9088,7 +9088,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
attributeName_ = s;
|
||||
@@ -9104,7 +9104,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getAttributeNameBytes() {
|
||||
java.lang.Object ref = attributeName_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
attributeName_ = b;
|
||||
@@ -9127,7 +9127,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
fullTagReference_ = s;
|
||||
@@ -9143,7 +9143,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getFullTagReferenceBytes() {
|
||||
java.lang.Object ref = fullTagReference_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
fullTagReference_ = b;
|
||||
@@ -9177,7 +9177,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
if (ref instanceof java.lang.String) {
|
||||
return (java.lang.String) ref;
|
||||
} else {
|
||||
com.google.protobuf.ByteString bs =
|
||||
com.google.protobuf.ByteString bs =
|
||||
(com.google.protobuf.ByteString) ref;
|
||||
java.lang.String s = bs.toStringUtf8();
|
||||
dataTypeName_ = s;
|
||||
@@ -9193,7 +9193,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getDataTypeNameBytes() {
|
||||
java.lang.Object ref = dataTypeName_;
|
||||
if (ref instanceof java.lang.String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
dataTypeName_ = b;
|
||||
@@ -9835,7 +9835,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getAttributeNameBytes() {
|
||||
java.lang.Object ref = attributeName_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
attributeName_ = b;
|
||||
@@ -9907,7 +9907,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getFullTagReferenceBytes() {
|
||||
java.lang.Object ref = fullTagReference_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
fullTagReference_ = b;
|
||||
@@ -10011,7 +10011,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
getDataTypeNameBytes() {
|
||||
java.lang.Object ref = dataTypeName_;
|
||||
if (ref instanceof String) {
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString b =
|
||||
com.google.protobuf.ByteString.copyFromUtf8(
|
||||
(java.lang.String) ref);
|
||||
dataTypeName_ = b;
|
||||
@@ -10335,52 +10335,52 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera
|
||||
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_TestConnectionRequest_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_TestConnectionRequest_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_TestConnectionReply_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_TestConnectionReply_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_GetLastDeployTimeRequest_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_GetLastDeployTimeRequest_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_GetLastDeployTimeReply_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_GetLastDeployTimeReply_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_DiscoverHierarchyRequest_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_DiscoverHierarchyRequest_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_DiscoverHierarchyReply_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_DiscoverHierarchyReply_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_WatchDeployEventsRequest_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_WatchDeployEventsRequest_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_DeployEvent_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_DeployEvent_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_GalaxyObject_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_GalaxyObject_fieldAccessorTable;
|
||||
private static final com.google.protobuf.Descriptors.Descriptor
|
||||
internal_static_galaxy_repository_v1_GalaxyAttribute_descriptor;
|
||||
private static final
|
||||
private static final
|
||||
com.google.protobuf.GeneratedMessage.FieldAccessorTable
|
||||
internal_static_galaxy_repository_v1_GalaxyAttribute_fieldAccessorTable;
|
||||
|
||||
|
||||
+17753
-334
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,22 @@ async with await GatewayClient.connect(
|
||||
events available for parity tests. `Session` helpers call the method-specific
|
||||
MXAccess commands and preserve raw replies on typed command exceptions.
|
||||
|
||||
`*_raw` methods (`GatewayClient.invoke_raw`, `Session.invoke_raw`) surface
|
||||
gateway protocol failures by raising the typed `MxGateway*` exceptions, but
|
||||
they deliberately do **not** run MXAccess-failure detection: an MXAccess
|
||||
HRESULT or `MxStatusProxy` status failure is left embedded in the returned
|
||||
reply and no `MxAccessError` is raised. `Session.invoke` adds that check on
|
||||
top. Parity-test callers using `invoke_raw` must inspect the reply's
|
||||
`protocol_status`, `hresult`, and `statuses` themselves. The non-raw `Session`
|
||||
helpers (`register`, `add_item`, `write`, the bulk methods, etc.) run the
|
||||
check and raise `MxAccessError`.
|
||||
|
||||
Value conversion (`to_mx_value`, used by `Session.write`/`write2` and the
|
||||
bulk helpers) rejects non-finite floats — `nan`, `inf`, and `-inf` raise
|
||||
`ValueError` rather than being forwarded to MXAccess, which has no defined
|
||||
wire representation for them. Python `bytes` values are an opaque
|
||||
`VT_RECORD` pass-through that MXAccess does not interpret.
|
||||
|
||||
Canceling a Python task cancels the client-side gRPC call or stream wait. It
|
||||
does not abort an in-flight MXAccess COM call inside the worker process.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "mxaccess-gateway-client"
|
||||
version = "0.1.0"
|
||||
description = "Async Python client scaffold for MXAccess Gateway."
|
||||
description = "Async Python client for MXAccess Gateway."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -72,14 +72,20 @@ class GatewayClient:
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the owned gRPC channel."""
|
||||
"""Close the owned gRPC channel.
|
||||
|
||||
Idempotent, including under concurrent calls: ``_closed`` is set
|
||||
before the ``await`` so a second coroutine entering ``close()``
|
||||
while the first is still awaiting the channel close returns
|
||||
immediately instead of issuing a second ``channel.close()``.
|
||||
"""
|
||||
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
|
||||
if self._channel is not None:
|
||||
await self._channel.close()
|
||||
self._closed = True
|
||||
|
||||
async def open_session(
|
||||
self,
|
||||
@@ -117,7 +123,15 @@ class GatewayClient:
|
||||
return reply
|
||||
|
||||
async def invoke_raw(self, request: pb.MxCommandRequest) -> pb.MxCommandReply:
|
||||
"""Send an `Invoke` RPC and return the raw reply."""
|
||||
"""Send an `Invoke` RPC and return the raw reply.
|
||||
|
||||
Enforces gateway protocol success only. MXAccess HRESULT/status
|
||||
failures are left embedded in the reply and do not raise
|
||||
`MxAccessError` — parity-test callers must inspect the reply's
|
||||
`protocol_status`, `hresult`, and `statuses` themselves. Use
|
||||
`Session.invoke` for the variant that also raises on MXAccess
|
||||
failure.
|
||||
"""
|
||||
reply = await self._unary("invoke", self.raw_stub.Invoke, request)
|
||||
ensure_protocol_success("invoke", reply.protocol_status, reply)
|
||||
return reply
|
||||
@@ -134,7 +148,7 @@ class GatewayClient:
|
||||
if self.options.stream_timeout is not None:
|
||||
kwargs["timeout"] = self.options.stream_timeout
|
||||
call = _open_stream(self.raw_stub.StreamEvents, request, kwargs)
|
||||
return _canceling_iterator(call)
|
||||
return _canceling_iterator(call, "stream events")
|
||||
|
||||
async def acknowledge_alarm(
|
||||
self,
|
||||
@@ -170,7 +184,7 @@ class GatewayClient:
|
||||
if self.options.stream_timeout is not None:
|
||||
kwargs["timeout"] = self.options.stream_timeout
|
||||
call = _open_stream(self.raw_stub.QueryActiveAlarms, request, kwargs)
|
||||
return _canceling_active_alarms_iterator(call)
|
||||
return _canceling_iterator(call, "query active alarms")
|
||||
|
||||
async def _unary(
|
||||
self,
|
||||
@@ -218,24 +232,26 @@ def _open_stream(method: Any, request: Any, kwargs: dict[str, Any]) -> Any:
|
||||
return method(request, **kwargs)
|
||||
|
||||
|
||||
async def _canceling_iterator(call: Any) -> AsyncIterator[pb.MxEvent]:
|
||||
async def _canceling_iterator(call: Any, operation: str) -> AsyncIterator[Any]:
|
||||
"""Yield from a server-streaming call and cancel it when iteration stops.
|
||||
|
||||
Explicitly catches :class:`asyncio.CancelledError` to cancel the
|
||||
underlying call before re-raising, then repeats the cancel in the
|
||||
``finally`` block so the call is also cancelled on a clean break or an
|
||||
``aclose()``. ``galaxy._canceling_iterator`` delegates here so the
|
||||
gateway and Galaxy stream helpers stay identical.
|
||||
"""
|
||||
|
||||
try:
|
||||
async for event in call:
|
||||
yield event
|
||||
async for item in call:
|
||||
yield item
|
||||
except asyncio.CancelledError:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
raise
|
||||
except grpc.RpcError as error:
|
||||
raise map_rpc_error("stream events", error) from error
|
||||
finally:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
|
||||
|
||||
async def _canceling_active_alarms_iterator(call: Any) -> AsyncIterator[pb.ActiveAlarmSnapshot]:
|
||||
try:
|
||||
async for snapshot in call:
|
||||
yield snapshot
|
||||
except grpc.RpcError as error:
|
||||
raise map_rpc_error("query active alarms", error) from error
|
||||
raise map_rpc_error(operation, error) from error
|
||||
finally:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
|
||||
@@ -138,7 +138,7 @@ def ensure_mxaccess_success(operation: str, reply: pb.MxCommandReply) -> pb.MxCo
|
||||
)
|
||||
|
||||
for mx_status in reply.statuses:
|
||||
if mx_status.success == 0:
|
||||
if _is_mxaccess_status_failure(mx_status):
|
||||
raise MxAccessError(
|
||||
_mxaccess_message(operation, reply),
|
||||
protocol_status=status,
|
||||
@@ -148,6 +148,28 @@ def ensure_mxaccess_success(operation: str, reply: pb.MxCommandReply) -> pb.MxCo
|
||||
return reply
|
||||
|
||||
|
||||
def _is_mxaccess_status_failure(mx_status: pb.MxStatusProxy) -> bool:
|
||||
"""Return ``True`` only for a populated MXAccess status reporting failure.
|
||||
|
||||
MXAccess uses ``success == 0`` as the failure flag, but ``0`` is also the
|
||||
proto3 scalar default. The gateway emits placeholder ``MxStatusProxy``
|
||||
entries with ``success`` unset for null ``MXSTATUS_PROXY`` COM entries
|
||||
(see ``MxStatusProxyConverter.ConvertMany``); such an entry has
|
||||
``category`` of ``UNSPECIFIED`` or ``UNKNOWN``. Treating it as a failure
|
||||
would raise ``MxAccessError`` for a reply that carries no real failure,
|
||||
so failure is keyed on ``success == 0`` together with a populated,
|
||||
non-OK status category.
|
||||
"""
|
||||
|
||||
if mx_status.success != 0:
|
||||
return False
|
||||
return mx_status.category not in (
|
||||
pb.MX_STATUS_CATEGORY_UNSPECIFIED,
|
||||
pb.MX_STATUS_CATEGORY_UNKNOWN,
|
||||
pb.MX_STATUS_CATEGORY_OK,
|
||||
)
|
||||
|
||||
|
||||
def _mxaccess_message(operation: str, reply: pb.MxCommandReply) -> str:
|
||||
status_text = reply.protocol_status.message or "MXAccess command failed"
|
||||
hresult = reply.hresult if reply.HasField("hresult") else None
|
||||
|
||||
@@ -18,6 +18,7 @@ import grpc
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
|
||||
from .auth import merge_metadata
|
||||
from .client import _canceling_iterator
|
||||
from .errors import MxGatewayError, map_rpc_error
|
||||
from .generated import galaxy_repository_pb2 as galaxy_pb
|
||||
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
||||
@@ -83,14 +84,20 @@ class GalaxyRepositoryClient:
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the owned gRPC channel."""
|
||||
"""Close the owned gRPC channel.
|
||||
|
||||
Idempotent, including under concurrent calls: ``_closed`` is set
|
||||
before the ``await`` so a second coroutine entering ``close()``
|
||||
while the first is still awaiting the channel close returns
|
||||
immediately instead of issuing a second ``channel.close()``.
|
||||
"""
|
||||
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
|
||||
if self._channel is not None:
|
||||
await self._channel.close()
|
||||
self._closed = True
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
"""Return ``True`` when the gateway can reach the Galaxy Repository DB."""
|
||||
@@ -189,7 +196,7 @@ class GalaxyRepositoryClient:
|
||||
kwargs.pop("timeout")
|
||||
call = self.raw_stub.WatchDeployEvents(request, **kwargs)
|
||||
|
||||
return _canceling_iterator(call)
|
||||
return _canceling_iterator(call, "watch deploy events")
|
||||
|
||||
async def _unary(
|
||||
self,
|
||||
@@ -218,20 +225,3 @@ class GalaxyRepositoryClient:
|
||||
raise
|
||||
except grpc.RpcError as error:
|
||||
raise map_rpc_error(operation, error) from error
|
||||
|
||||
|
||||
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
|
||||
try:
|
||||
async for event in call:
|
||||
yield event
|
||||
except asyncio.CancelledError:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
raise
|
||||
except grpc.RpcError as error:
|
||||
raise map_rpc_error("watch deploy events", error) from error
|
||||
finally:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .errors import ensure_mxaccess_success
|
||||
from .generated import mxaccess_gateway_pb2 as pb
|
||||
from .values import MxValueInput, to_mx_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import GatewayClient
|
||||
|
||||
MAX_BULK_ITEMS = 1000
|
||||
|
||||
|
||||
@@ -36,7 +40,13 @@ class Session:
|
||||
await self.close()
|
||||
|
||||
async def close(self, *, client_correlation_id: str = "") -> pb.CloseSessionReply:
|
||||
"""Close the gateway session. Repeated calls return a local closed reply."""
|
||||
"""Close the gateway session. Repeated calls return a local closed reply.
|
||||
|
||||
Idempotent, including under concurrent calls: ``_closed`` is set
|
||||
before the ``CloseSession`` RPC is awaited so a second coroutine
|
||||
entering ``close()`` while the first RPC is in flight returns the
|
||||
local closed reply instead of issuing a second ``CloseSession``.
|
||||
"""
|
||||
|
||||
if self._closed:
|
||||
return pb.CloseSessionReply(
|
||||
@@ -44,15 +54,14 @@ class Session:
|
||||
final_state=pb.SESSION_STATE_CLOSED,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
)
|
||||
self._closed = True
|
||||
|
||||
reply = await self.client.close_session_raw(
|
||||
return await self.client.close_session_raw(
|
||||
pb.CloseSessionRequest(
|
||||
session_id=self.session_id,
|
||||
client_correlation_id=client_correlation_id,
|
||||
),
|
||||
)
|
||||
self._closed = True
|
||||
return reply
|
||||
|
||||
async def invoke(self, command: pb.MxCommand, *, correlation_id: str = "") -> pb.MxCommandReply:
|
||||
"""Invoke a raw command and enforce gateway and MXAccess success."""
|
||||
@@ -66,7 +75,15 @@ class Session:
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> pb.MxCommandReply:
|
||||
"""Invoke a raw command and preserve the raw reply."""
|
||||
"""Invoke a raw command and preserve the raw reply.
|
||||
|
||||
Enforces gateway protocol success only — unlike :meth:`invoke`, it
|
||||
does not run MXAccess-failure detection. An MXAccess HRESULT or
|
||||
``MxStatusProxy`` status failure is left embedded in the returned
|
||||
reply and no ``MxAccessError`` is raised. Parity-test callers must
|
||||
inspect ``protocol_status``, ``hresult``, and ``statuses`` on the
|
||||
reply themselves.
|
||||
"""
|
||||
|
||||
return await self.client.invoke_raw(
|
||||
pb.MxCommandRequest(
|
||||
@@ -399,6 +416,3 @@ class Session:
|
||||
def _ensure_bulk_size(name: str, count: int) -> None:
|
||||
if count > MAX_BULK_ITEMS:
|
||||
raise ValueError(f"{name} bulk commands are limited to {MAX_BULK_ITEMS} item(s)")
|
||||
|
||||
|
||||
from .client import GatewayClient # noqa: E402
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
"""MXAccess value conversion helpers."""
|
||||
"""MXAccess value conversion helpers.
|
||||
|
||||
Value-mapping assumptions (see ``to_mx_value``):
|
||||
|
||||
* A Python ``float`` maps to ``VT_R8`` / ``MX_DATA_TYPE_DOUBLE``. Only finite
|
||||
values are accepted — ``nan``, ``inf`` and ``-inf`` raise ``ValueError``
|
||||
rather than being forwarded to MXAccess, which has no defined wire
|
||||
representation for non-finite doubles.
|
||||
* A Python ``bytes`` value maps to ``VT_RECORD`` / ``MX_DATA_TYPE_UNKNOWN``
|
||||
and is carried in ``raw_value``. This is an opaque pass-through: MXAccess
|
||||
does not interpret the bytes. Pass ``data_type`` explicitly when a concrete
|
||||
MXAccess type is required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -60,6 +73,7 @@ def to_mx_value(value: MxValueInput, *, data_type: str | None = None) -> pb.MxVa
|
||||
)
|
||||
|
||||
if isinstance(value, float):
|
||||
_ensure_finite(value)
|
||||
return pb.MxValue(
|
||||
data_type=_data_type(data_type, pb.MX_DATA_TYPE_DOUBLE),
|
||||
variant_type="VT_R8",
|
||||
@@ -177,6 +191,8 @@ def _sequence_to_mx_value(
|
||||
return pb.MxValue(data_type=pb.MX_DATA_TYPE_INTEGER, array_value=array)
|
||||
|
||||
if all(isinstance(item, float) for item in sequence):
|
||||
for item in sequence:
|
||||
_ensure_finite(item)
|
||||
array = pb.MxArray(
|
||||
element_data_type=pb.MX_DATA_TYPE_DOUBLE,
|
||||
variant_type="VT_ARRAY|VT_R8",
|
||||
@@ -232,3 +248,12 @@ def _data_type(name: str | None, default: int) -> int:
|
||||
if name is None:
|
||||
return default
|
||||
return pb.MxDataType.Value(name)
|
||||
|
||||
|
||||
def _ensure_finite(value: float) -> None:
|
||||
"""Reject non-finite doubles, which MXAccess cannot represent on the wire."""
|
||||
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(
|
||||
f"MxValue double inputs must be finite; got {value!r}",
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from mxgateway.client import GatewayClient
|
||||
from mxgateway.errors import MxGatewayError
|
||||
from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from mxgateway.options import ClientOptions
|
||||
from mxgateway.session import Session
|
||||
from mxgateway.values import MxValueInput
|
||||
|
||||
MAX_AGGREGATE_EVENTS = 10_000
|
||||
@@ -383,8 +384,7 @@ async def _write2(**kwargs: Any) -> dict[str, Any]:
|
||||
async def _smoke(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = await client.open_session(client_session_name=kwargs["client_name"])
|
||||
closed = False
|
||||
try:
|
||||
async with session:
|
||||
server_handle = await session.register(kwargs["client_name"])
|
||||
item_handle = await session.add_item(server_handle, kwargs["item"])
|
||||
await session.advise(server_handle, item_handle)
|
||||
@@ -399,9 +399,6 @@ async def _smoke(**kwargs: Any) -> dict[str, Any]:
|
||||
"itemHandle": item_handle,
|
||||
"events": [_message_dict(event) for event in events],
|
||||
}
|
||||
finally:
|
||||
if not closed:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def _connect(kwargs: dict[str, Any]) -> GatewayClient:
|
||||
@@ -419,9 +416,7 @@ async def _connect(kwargs: dict[str, Any]) -> GatewayClient:
|
||||
)
|
||||
|
||||
|
||||
def _session(client: GatewayClient, session_id: str):
|
||||
from mxgateway.session import Session
|
||||
|
||||
def _session(client: GatewayClient, session_id: str) -> Session:
|
||||
return Session(client=client, session_id=session_id)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Regression tests for Client.Python low-severity code-review findings.
|
||||
|
||||
Covers Client.Python-006 (concurrent-close idempotency),
|
||||
Client.Python-007 (shared cancelling stream helper),
|
||||
Client.Python-008 (non-finite float / bytes value mapping), and
|
||||
Client.Python-011 (`success == 0` proto3-default ambiguity).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from mxgateway import ClientOptions, GalaxyRepositoryClient, GatewayClient
|
||||
from mxgateway.errors import ensure_mxaccess_success, MxAccessError
|
||||
from mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from mxgateway.values import to_mx_value
|
||||
|
||||
|
||||
# --- Client.Python-006: concurrent close() is idempotent -------------------
|
||||
|
||||
|
||||
class CountingChannel:
|
||||
"""A fake gRPC channel that records and stalls on close()."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.close_calls = 0
|
||||
self._gate = asyncio.Event()
|
||||
|
||||
async def close(self) -> None:
|
||||
self.close_calls += 1
|
||||
# Yield control so a second concurrent close() can interleave at the
|
||||
# exact point a check-then-set guard would have left the window open.
|
||||
await self._gate.wait()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_client_concurrent_close_closes_channel_once() -> None:
|
||||
channel = CountingChannel()
|
||||
client = GatewayClient(
|
||||
options=ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=object(),
|
||||
channel=channel, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
first = asyncio.create_task(client.close())
|
||||
second = asyncio.create_task(client.close())
|
||||
await asyncio.sleep(0) # let both coroutines pass the guard if racy
|
||||
|
||||
channel._gate.set()
|
||||
await asyncio.gather(first, second)
|
||||
|
||||
assert channel.close_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_galaxy_client_concurrent_close_closes_channel_once() -> None:
|
||||
channel = CountingChannel()
|
||||
client = GalaxyRepositoryClient(
|
||||
options=ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=object(),
|
||||
channel=channel, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
first = asyncio.create_task(client.close())
|
||||
second = asyncio.create_task(client.close())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
channel._gate.set()
|
||||
await asyncio.gather(first, second)
|
||||
|
||||
assert channel.close_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_concurrent_close_sends_one_close_session_rpc() -> None:
|
||||
gate = asyncio.Event()
|
||||
rpc_calls = 0
|
||||
|
||||
class StallingClient:
|
||||
async def close_session_raw(self, request: Any) -> pb.CloseSessionReply:
|
||||
nonlocal rpc_calls
|
||||
rpc_calls += 1
|
||||
await gate.wait()
|
||||
return pb.CloseSessionReply(
|
||||
session_id=request.session_id,
|
||||
final_state=pb.SESSION_STATE_CLOSED,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
)
|
||||
|
||||
from mxgateway.session import Session
|
||||
|
||||
session = Session(client=StallingClient(), session_id="session-1") # type: ignore[arg-type]
|
||||
|
||||
first = asyncio.create_task(session.close())
|
||||
second = asyncio.create_task(session.close())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
gate.set()
|
||||
await asyncio.gather(first, second)
|
||||
|
||||
assert rpc_calls == 1
|
||||
|
||||
|
||||
# --- Client.Python-007: shared cancelling stream helper --------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_stream_iterator_cancels_call_on_task_cancellation() -> None:
|
||||
"""A cancelled gateway stream iterator must explicitly cancel the call."""
|
||||
|
||||
class CancellableStream:
|
||||
def __init__(self) -> None:
|
||||
self.cancelled = False
|
||||
|
||||
def __aiter__(self) -> "CancellableStream":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> pb.MxEvent:
|
||||
await asyncio.Event().wait() # blocks until cancelled
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
from mxgateway.client import _canceling_iterator
|
||||
|
||||
stream = CancellableStream()
|
||||
iterator = _canceling_iterator(stream, "stream events")
|
||||
|
||||
task = asyncio.create_task(anext(iterator))
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
# aclose() unwinds the generator's finally block.
|
||||
await iterator.aclose()
|
||||
|
||||
assert stream.cancelled
|
||||
|
||||
|
||||
# --- Client.Python-008: non-finite float and bytes value mapping -----------
|
||||
|
||||
|
||||
def test_to_mx_value_rejects_nan() -> None:
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
to_mx_value(float("nan"))
|
||||
|
||||
|
||||
def test_to_mx_value_rejects_positive_infinity() -> None:
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
to_mx_value(float("inf"))
|
||||
|
||||
|
||||
def test_to_mx_value_rejects_negative_infinity() -> None:
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
to_mx_value(float("-inf"))
|
||||
|
||||
|
||||
def test_to_mx_value_accepts_finite_float() -> None:
|
||||
assert to_mx_value(3.5).double_value == 3.5
|
||||
|
||||
|
||||
def test_to_mx_value_rejects_non_finite_float_in_sequence() -> None:
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
to_mx_value([1.0, math.inf])
|
||||
|
||||
|
||||
# --- Client.Python-011: success == 0 proto3-default ambiguity --------------
|
||||
|
||||
|
||||
def test_ensure_mxaccess_success_ignores_unpopulated_status_entry() -> None:
|
||||
"""A status entry left at proto3 defaults is not a real MXAccess failure.
|
||||
|
||||
The gateway emits such a placeholder for a null MXSTATUS_PROXY COM entry
|
||||
(``MxStatusProxyConverter.ConvertMany``): ``success`` stays 0 but the
|
||||
entry carries no failure category. It must not raise ``MxAccessError``.
|
||||
"""
|
||||
|
||||
reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_SUBSCRIBE_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
statuses=[
|
||||
pb.MxStatusProxy(), # all-default: success == 0, category UNSPECIFIED
|
||||
pb.MxStatusProxy( # the gateway's null-entry placeholder
|
||||
category=pb.MX_STATUS_CATEGORY_UNKNOWN,
|
||||
detected_by=pb.MX_STATUS_SOURCE_UNKNOWN,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert ensure_mxaccess_success("subscribe bulk", reply) is reply
|
||||
|
||||
|
||||
def test_ensure_mxaccess_success_raises_on_populated_failure_status() -> None:
|
||||
"""A populated failure status (success == 0 with a failure category) raises."""
|
||||
|
||||
reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
statuses=[
|
||||
pb.MxStatusProxy(
|
||||
success=0,
|
||||
category=pb.MX_STATUS_CATEGORY_COMMUNICATION_ERROR,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(MxAccessError):
|
||||
ensure_mxaccess_success("write", reply)
|
||||
|
||||
|
||||
def test_ensure_mxaccess_success_passes_when_status_reports_success() -> None:
|
||||
reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
statuses=[
|
||||
pb.MxStatusProxy(success=1, category=pb.MX_STATUS_CATEGORY_OK),
|
||||
],
|
||||
)
|
||||
|
||||
assert ensure_mxaccess_success("write", reply) is reply
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-18 |
|
||||
| Commit reviewed | `3cc53a8` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 5 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -78,13 +78,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `clients/dotnet/MxGateway.Client/MxGatewayClient.cs:283-294`, `clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs:392-403` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `ExecuteSafeUnaryAsync` wraps the whole Polly retry pipeline in a single linked CTS cancelled after `Options.DefaultCallTimeout`, while `CreateCallOptions` also stamps each individual call with a `DefaultCallTimeout` gRPC deadline. The retry pipeline therefore shares one `DefaultCallTimeout` budget across the initial attempt plus all retries plus backoff delays. The README/XML docs describe `DefaultCallTimeout` as a per-call timeout, which misrepresents this. `DeadlineExceeded` is also classified as transient, so an attempt that exhausts the shared budget is retried only to immediately fail again.
|
||||
|
||||
**Recommendation:** Decide whether `DefaultCallTimeout` is per-attempt or per-operation and make code and docs consistent — e.g. a separate per-attempt deadline and a distinct overall-operation timeout. Reconsider retrying on `DeadlineExceeded` when the deadline was client-imposed.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Confirmed against source: the shared linked-CTS budget plus per-call deadline both use `DefaultCallTimeout`, and `IsTransientStatus` listed `DeadlineExceeded`. Resolved as a per-operation budget (the simpler, non-breaking choice): the `DefaultCallTimeout` XML doc in `MxGatewayClientOptions.cs` now states it is both the per-attempt gRPC deadline and the overall budget shared across the initial attempt, every retry, and the backoff delays — an upper bound on total wall-clock time, not a fresh per-retry allowance. Removed `DeadlineExceeded` from `MxGatewayClientRetryPolicy.IsTransientStatus`: every unary deadline is client-imposed (`CreateCallOptions` stamps the shared budget), so a `DeadlineExceeded` means the budget is exhausted and an immediate retry can only fail again. Regression test `MxGatewayClientSessionTests.InvokeAsync_DoesNotRetrySafeDiagnosticCommand_OnDeadlineExceeded` asserts the safe diagnostic command (`Ping`) is attempted exactly once and the failure surfaces; verified red against the original transient set (the call retried and succeeded).
|
||||
|
||||
### Client.Dotnet-005
|
||||
|
||||
@@ -93,13 +93,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `clients/dotnet/MxGateway.Client/MxGatewaySession.cs:82,124,175` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `RegisterAsync`/`AddItemAsync`/`AddItem2Async` return `reply.<Typed>?.ServerHandle ?? reply.ReturnValue.Int32Value`. After `EnsureMxAccessSuccess()` passes, a missing typed payload silently falls back to `ReturnValue.Int32Value`, which for a reply carrying no return value is `0`. A caller then uses `0` as a `ServerHandle`/`ItemHandle`, producing a confusing downstream invalid-handle failure rather than a clear "gateway reply missing payload" error.
|
||||
|
||||
**Recommendation:** If the typed sub-message is the contract for these commands, treat its absence on an otherwise-successful reply as an error (throw a descriptive `MxGatewayException`) rather than falling through to `ReturnValue.Int32Value`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Confirmed against source and `mxaccess_gateway.proto`: `register`/`add_item`/`add_item2` are members of the `MxCommandReply.payload` oneof, so the typed accessor is `null` whenever the worker did not set that case — and the fallback returned `ReturnValue.Int32Value` (0 for a reply with no return value). The typed sub-message is the contract for these handle-returning commands, so its absence on an otherwise-successful reply is now an error: `RegisterAsync`/`AddItemAsync`/`AddItem2Async` throw via a new private `MxGatewaySession.CreateMissingPayloadException` helper that builds a descriptive `MxGatewayException` naming the missing payload, kind, session, and correlation id. Regression tests `MxGatewayClientSessionTests.RegisterAsync_Throws_WhenSuccessfulReplyMissingPayload` and `AddItemAsync_Throws_WhenSuccessfulReplyMissingPayload` enqueue an `Ok` reply with no typed payload and assert the descriptive throw; verified red against the original fallback (returned `0` instead of throwing).
|
||||
|
||||
### Client.Dotnet-006
|
||||
|
||||
@@ -108,13 +108,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs:50`, `clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs:10-14` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `MxGatewayClientOptions.MaxGrpcMessageBytes` and the two `const`s in `MxGatewayClientContractInfo` are public members with no XML doc comments, inconsistent with every other public member in the assembly and with the repo's documented C# style emphasis on a documented public surface.
|
||||
|
||||
**Recommendation:** Add `<summary>` doc comments to `MaxGrpcMessageBytes`, `GatewayProtocolVersion`, and `WorkerProtocolVersion`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Confirmed: all three public members lacked XML docs while every other public member in the assembly is documented. Added `<summary>` comments to `MxGatewayClientOptions.MaxGrpcMessageBytes` (describing the 16 MiB default applied to both send and receive limits), and to `MxGatewayClientContractInfo.GatewayProtocolVersion` and `WorkerProtocolVersion` (describing their wire-compatibility / diagnostics purpose). Pure documentation change — no test needed; build remains warning-clean.
|
||||
|
||||
### Client.Dotnet-007
|
||||
|
||||
@@ -123,13 +123,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `clients/dotnet/MxGateway.Client/MxGatewayClient.cs:185-192` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The `AcknowledgeAlarmAsync` XML comment states the gateway authenticates against an `invoke:alarm-ack` scope, but `CLAUDE.md` documents the scope set without any `invoke:alarm-ack` sub-scope. The comment may describe an intended finer-grained scope that does not exist, misleading integrators about what API key they need.
|
||||
|
||||
**Recommendation:** Reconcile the comment with the actual server-side scope check, or update the scope documentation if sub-scopes were genuinely added; keep client doc and gateway auth model in sync.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Confirmed against the server-side authorization model: `GatewayGrpcScopeResolver.ResolveRequiredScope` has no arm for `AcknowledgeAlarmRequest`, so it falls to the `_ => GatewayScopes.Admin` default — the RPC actually requires the `admin` scope. No `invoke:alarm-ack` sub-scope exists anywhere in `GatewayScopes`. The client XML comment on `AcknowledgeAlarmAsync` was wrong, not the docs. Corrected the comment to state the gateway authorizes `AcknowledgeAlarmRequest` against the API key's `admin` scope and that there is no finer-grained alarm-ack sub-scope. Pure documentation change — no test needed.
|
||||
|
||||
### Client.Dotnet-008
|
||||
|
||||
@@ -138,10 +138,10 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `clients/dotnet/MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs:9-17` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The CLI redactor only removes the API key string when it was supplied via `--api-key`; `RunCoreAsync` passes `arguments.GetOptional("api-key")` to `Redact`. When the key comes from an environment variable (`--api-key-env`, the documented default path), `apiKey` is `null` and no redaction occurs. If a gRPC/transport error message ever echoes the bearer token, it would be printed unredacted.
|
||||
|
||||
**Recommendation:** Resolve the effective API key (same logic as `ResolveApiKey`) before redacting, so the env-var-sourced key is also stripped from error output.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Confirmed against source: `MxGatewayClientCli.RunCoreAsync`'s catch block redacted only `arguments.GetOptional("api-key")`, so an env-var-sourced key (`--api-key-env`, default `MXGATEWAY_API_KEY`) was never stripped. Note `MxGatewayCliSecretRedactor` itself is correct — the defect was the caller passing the wrong value. Extracted a non-throwing `TryResolveApiKey` helper (used by both the existing `ResolveApiKey` and the catch block) that resolves `--api-key` then the `--api-key-env` environment variable; the catch block now redacts that effective key. Updated `clients/dotnet/README.md` (`smoke` paragraph) to state the CLI redacts the effective key whether from `--api-key` or `--api-key-env`. Regression test `MxGatewayClientCliTests.RunAsync_ErrorOutput_RedactsApiKey_WhenSourcedFromEnvironmentVariable` sets a test env var, forces a transport error echoing the key, and asserts the key is absent and `[redacted]` is present; verified red against the original `GetOptional("api-key")`-only redaction (key printed unredacted).
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-18 |
|
||||
| Commit reviewed | `3cc53a8` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 7 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -78,13 +78,13 @@
|
||||
| Severity | Low |
|
||||
| Category | mxaccessgw conventions |
|
||||
| Location | `clients/go/mxgateway/alarms_test.go:153-154`, `clients/go/mxgateway/galaxy_test.go:58-59` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `gofmt -l` flags `alarms_test.go` and `galaxy_test.go` for misaligned struct-literal field padding. The Go client README lists `gofmt` as part of the workflow and the repo enforces style; unformatted committed code breaks `gofmt`-gated checks and CI.
|
||||
|
||||
**Recommendation:** Run `gofmt -w mxgateway/alarms_test.go mxgateway/galaxy_test.go`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: confirmed `gofmt -l .` flagged both files for misaligned struct-literal padding. Ran `gofmt -w` on `mxgateway/alarms_test.go` and `mxgateway/galaxy_test.go`; `gofmt -l .` is now clean for the whole module.
|
||||
|
||||
### Client.Go-005
|
||||
|
||||
@@ -93,13 +93,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Design-document adherence |
|
||||
| Location | `clients/go/mxgateway/client.go:64,68`, `clients/go/mxgateway/galaxy.go:83,87` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The client uses `grpc.DialContext` with `grpc.WithBlock()`. In current grpc-go both are deprecated in favour of `grpc.NewClient` (lazy connection). `WithBlock` also changes failure semantics: a transient gateway-unavailable at dial time becomes a hard `Dial` error rather than a connection that recovers when the gateway comes up, working against the design doc's resilience intent.
|
||||
|
||||
**Recommendation:** Migrate to `grpc.NewClient`; if a fail-fast connect probe is still wanted, do an explicit readiness wait bounded by `DialTimeout`, and update the doc comment.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: confirmed `Dial`/`DialGalaxy` used the deprecated `grpc.DialContext` + `grpc.WithBlock` pair. Migrated both to the shared `dial(ctx, opts)` helper, which now builds a lazy connection with `grpc.NewClient` and runs an explicit `waitForReady` readiness probe (`Connect` + `WaitForStateChange` until `connectivity.Ready`) bounded by `DialTimeout` — preserving fail-fast behavior while letting an otherwise lazy connection recover when the gateway is briefly down. Note: `grpc.NewClient` defaults the target scheme to `dns`, so the bufconn test harnesses (`client_session_test.go`, `alarms_test.go`, `galaxy_test.go`) were updated to use `passthrough:///bufnet` so the fake target reaches the context dialer. New tests `TestDialFailsFastWhenGatewayUnreachable` and `TestDialReadinessProbeReachesReady` cover the probe; `go vet` reports no deprecation. `clients/go/README.md` documents the lazy-connect + readiness-probe semantics.
|
||||
|
||||
### Client.Go-006
|
||||
|
||||
@@ -108,13 +108,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `clients/go/mxgateway/errors.go:9-130` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `docs/ClientLibrariesDesign.md` recommends a high-level error taxonomy (`TransportError`, `AuthenticationError`, `TimeoutError`, etc.). The Go client collapses all transport/gRPC failures into a single `GatewayError` with no way to classify transient (`Unavailable`, `DeadlineExceeded`) vs permanent (`Unauthenticated`, `InvalidArgument`) without manually unwrapping and calling `status.Code`.
|
||||
|
||||
**Recommendation:** Add a helper (e.g. `IsTransient(err) bool`) or expose the gRPC `codes.Code` on `GatewayError`, so retry/timeout/auth handling can be written without re-parsing the wrapped error.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: implemented the recommended classification surface in `errors.go` rather than a full parallel type hierarchy (the existing `GatewayError`/`CommandError`/`MxAccessError` chain already separates transport from protocol from MXAccess failures). Added `GatewayError.Code()` (returns the wrapped gRPC `codes.Code`, `OK` for nil, `Unknown` for a non-status error) and the free function `IsTransient(err error) bool`, which unwraps through `*GatewayError` and any gRPC-status chain and reports `true` for `Unavailable`, `DeadlineExceeded`, `ResourceExhausted`, and `Aborted`. Tests `TestGatewayErrorCode` and `TestIsTransient` cover the matrix; `clients/go/README.md` documents both for retry/timeout/auth handling.
|
||||
|
||||
### Client.Go-007
|
||||
|
||||
@@ -123,13 +123,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `clients/go/mxgateway/session.go:526-532` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `newCorrelationID` returns an empty string when `crypto/rand.Read` fails, silently producing an `MxCommandRequest` with no correlation id. `rand.Read` failure is rare, but the failure mode (untraceable command, no error surfaced) is worse than failing loud, and the empty-id path is untested.
|
||||
|
||||
**Recommendation:** Either propagate the error up through `invokeCommand`, or fall back to a time/counter-based id rather than an empty string.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: confirmed `newCorrelationID` returned `""` on a `rand.Read` failure. It now falls back to a non-empty `"fallback-<unixnano>-<counter>"` id built from `time.Now().UnixNano()` and a process-wide `atomic.Uint64` monotonic counter, so every command stays traceable even without entropy. The `crypto/rand` call was routed through a `randRead` package variable so the failure path is testable; `TestNewCorrelationIDFallsBackOnRandFailure` simulates a `rand.Read` failure and asserts the fallback id is non-empty, `fallback-` prefixed, and unique, and `TestNewCorrelationIDUsesRandEntropy` pins the happy path.
|
||||
|
||||
### Client.Go-008
|
||||
|
||||
@@ -138,13 +138,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Location | `clients/go/mxgateway/` (test files) |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Several critical paths are untested: TLS credential resolution in `resolveTransportCredentials` (only the `Plaintext` path is exercised); the `callContext` deadline-shortening logic (`client.go:198-204`) including the negative-timeout disable case; and `NativeValue`/`NativeArray` for the array, raw-bytes, null, and unsupported-kind branches.
|
||||
|
||||
**Recommendation:** Add unit tests for `resolveTransportCredentials` precedence, `callContext` deadline arithmetic, and `NativeValue`/`NativeArray` round-trips for every kind.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: added `clients/go/mxgateway/coverage_test.go`. `TestResolveTransportCredentialsPrecedence` exercises every branch (explicit `TransportCredentials`, `Plaintext`, missing `CACertFile` error, `TLSConfig` + `ServerNameOverride`, default TLS floor) and `TestResolveTransportCredentialsDoesNotMutateTLSConfig` confirms the supplied `*tls.Config` is cloned. `TestCallContextDeadlineArithmetic` covers zero/default, negative-disable, positive timeout, caller-deadline-sooner-kept, and caller-deadline-later-shortened. `TestNativeValueEdgeKinds`, `TestNativeArrayEdgeKinds`, and `TestNativeValueUnsupportedKind` cover the null, raw-bytes (including the no-alias copy), array, timestamp-with-nil, and unsupported-kind branches.
|
||||
|
||||
### Client.Go-009
|
||||
|
||||
@@ -153,13 +153,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `clients/go/mxgateway/galaxy.go:60-93,241-256`, `clients/go/mxgateway/client.go:41-74,190-205` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `DialGalaxy`/`Dial` and `GalaxyClient.callContext`/`Client.callContext` are near-identical duplicates (dial-context setup, credential resolution, dial-option assembly, deadline arithmetic). A fix to one (e.g. the Client.Go-005 dial migration) must be applied twice and can drift.
|
||||
|
||||
**Recommendation:** Extract a shared unexported `dial(ctx, opts)` and a free `callContext(opts, ctx)` function, and have both client constructors call them.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: extracted the shared unexported `dial(ctx, opts) (*grpc.ClientConn, error)` (credential resolution, dial-option assembly, `grpc.NewClient`, readiness probe) and the free `callContext(ctx, callTimeout) (context.Context, context.CancelFunc)` into `client.go`. `Dial`/`DialGalaxy` and both `(*Client).callContext`/`(*GalaxyClient).callContext` methods now delegate to them; the duplicated dial and deadline code in `galaxy.go` was removed (its now-unused `errors` import dropped). This was done together with the Client.Go-005 migration so the `grpc.NewClient` change lives in exactly one place.
|
||||
|
||||
### Client.Go-010
|
||||
|
||||
@@ -168,10 +168,10 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `clients/go/mxgateway/client.go:39-40` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The `Dial` doc comment states it configures "blocking dial cancellation from ctx." This describes the deprecated `WithBlock` behaviour; once Client.Go-005 is addressed the comment is misleading about how connection establishment and cancellation work.
|
||||
|
||||
**Recommendation:** Reword to describe the actual connect/timeout semantics after resolving Client.Go-005, and clarify that `DialTimeout` bounds the initial connect attempt.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: alongside the Client.Go-005 migration, the `Dial` doc comment was rewritten to describe the lazy `grpc.NewClient` connection, the `DialTimeout`-bounded (default 10s, or ctx deadline when sooner) readiness probe, that a briefly-unavailable gateway recovers instead of producing a hard error, and that cancelling `ctx` aborts the probe. `DialGalaxy` and the new `dial`/`waitForReady`/`callContext` helpers carry matching doc comments.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-18 |
|
||||
| Commit reviewed | `3cc53a8` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 7 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -108,13 +108,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Performance & resource management |
|
||||
| Location | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:323-328`, `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClient.java:279-284` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `close()` (the `AutoCloseable` method invoked by try-with-resources) calls only `ownedChannel.shutdown()` and returns immediately without awaiting termination. In-flight calls and Netty event-loop threads may still be running when the caller assumes the resource is released. `closeAndAwaitTermination()` does it correctly but is not the method try-with-resources uses, and the README examples all rely on try-with-resources.
|
||||
|
||||
**Recommendation:** Have `close()` await termination for a bounded time and `shutdownNow()` on timeout (the logic already in `closeAndAwaitTermination()`), or document that try-with-resources callers should call `closeAndAwaitTermination()`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Confirmed against source: both `MxGatewayClient.close()` and `GalaxyRepositoryClient.close()` called only `ownedChannel.shutdown()`. `close()` in both clients now performs the bounded-wait logic previously only in `closeAndAwaitTermination()`: it shuts the channel down, waits up to the configured connect timeout for graceful termination, and calls `shutdownNow()` on timeout. Because `close()` cannot throw a checked exception, an `InterruptedException` while awaiting is handled by forcibly shutting the channel down and restoring the thread interrupt flag. `closeAndAwaitTermination()` is retained unchanged for callers who want the checked, blocking-aware variant. `clients/java/README.md` documents the new try-with-resources `close()` semantics.
|
||||
|
||||
### Client.Java-007
|
||||
|
||||
@@ -123,13 +123,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Location | `clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The alarm surface — `acknowledgeAlarm`/`acknowledgeAlarmAsync`/`queryActiveAlarms` and `MxGatewayActiveAlarmsSubscription` — has zero test coverage. TLS channel construction, the async `streamEventsAsync` path, `MxGatewayEventSubscription` pre-start cancellation, and `MxEventStream` queue overflow are likewise untested. `JavaClientDesign.md` explicitly lists async stream-observer cancellation and status/error mapping as required tests.
|
||||
|
||||
**Recommendation:** Add in-process gRPC tests for the alarm RPCs, the async streaming/subscription cancellation paths, and at least one TLS-config construction test.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Confirmed against source: no test referenced `acknowledgeAlarm`, `queryActiveAlarms`, `streamEventsAsync`, TLS construction, or `MxEventStream` overflow. Added `MxGatewayLowFindingsTests` (12 tests) covering: `acknowledgeAlarm`/`acknowledgeAlarmAsync` (success, typed protocol-failure, async transport-failure normalisation), `queryActiveAlarms` observer delivery, `MxGatewayActiveAlarmsSubscription` and `MxGatewayEventSubscription` pre-start cancellation, `streamEventsAsync` observer delivery, `MxEventStream` queue overflow surfacing `MxGatewayException`, TLS channel construction (missing CA file rejected with a typed exception, system-trust path builds cleanly), and the Client.Java-008 async-validator normalisation. While writing the TLS test a latent bug was found: a missing/unreadable CA file makes `GrpcSslContexts` throw `IllegalArgumentException` (not `SSLException`), which the old `catch (SSLException)` let escape unwrapped — the catch in the shared channel builder was broadened to also wrap `RuntimeException` so callers always see one typed `MxGatewayException`.
|
||||
|
||||
### Client.Java-008
|
||||
|
||||
@@ -138,13 +138,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:298-304` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `acknowledgeAlarmAsync` and `openSessionAsync` apply `ensureProtocolSuccess` inside `thenApply`. If that validator throws a non-`MxGatewayException` `RuntimeException` it is wrapped by `CompletionException` with no `fromGrpc` normalisation, unlike the synchronous paths which normalise via `try/catch`. The async and sync error surfaces are therefore inconsistent.
|
||||
|
||||
**Recommendation:** Wrap the `thenApply` body so any non-`MxGatewayException` is routed through `MxGatewayErrors.fromGrpc`, matching the synchronous methods.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Confirmed against source: the `thenApply` validators in `openSessionAsync`, `invokeAsync`, and `acknowledgeAlarmAsync` were not normalised — in practice the gateway's own validators (`ensureProtocolSuccess`, `ensureMxAccessSuccess`, `ensureGatewayProtocolCompatible`) only ever throw `MxGatewayException`, but a stray non-`MxGatewayException` `RuntimeException` (e.g. an NPE from a malformed reply) would surface raw inside `CompletionException`. Added `MxGatewayChannels.normalisingValidator(operation, fn)`: it rethrows `MxGatewayException` unchanged and routes any other `RuntimeException` through `MxGatewayErrors.fromGrpc`, matching the synchronous `try/catch` paths. All three async `thenApply` sites now use it. Regression test: `MxGatewayLowFindingsTests.openSessionAsyncNormalisesNonGatewayRuntimeExceptionFromValidator`.
|
||||
|
||||
### Client.Java-009
|
||||
|
||||
@@ -153,13 +153,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClient.java:310-391`, `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:346-413` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `createChannel`, `withDeadline`, `withStreamDeadline`, and `toCompletable` are duplicated nearly verbatim across `MxGatewayClient` and `GalaxyRepositoryClient` (~80 lines). A fix to one will not propagate to the other.
|
||||
|
||||
**Recommendation:** Extract the channel-builder and future-adaptor helpers into a shared package-private utility class.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Confirmed against source: the four helpers were duplicated near-verbatim. Added a package-private `MxGatewayChannels` utility class holding `createChannel(options, tlsErrorPrefix)`, `withDeadline(stub, options)`, `withStreamDeadline(stub, options)`, `toCompletable(future, operation)`, and the new `normalisingValidator` helper (Client.Java-008). Both `MxGatewayClient` and `GalaxyRepositoryClient` now delegate to it and their private copies were deleted, so a future fix lives in one place. Behavior is unchanged except the operation-name carried into `MxGatewayErrors.fromGrpc` is now the specific RPC name instead of the generic `"async call"`/`"galaxy async call"`. Verified by the full existing async test suite plus the new `MxGatewayLowFindingsTests`.
|
||||
|
||||
### Client.Java-010
|
||||
|
||||
@@ -168,13 +168,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:269-272`, `clients/java/README.md:76` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The `acknowledgeAlarm` Javadoc states the gateway authenticates against an `invoke:alarm-ack` scope, and the README states the Galaxy Repository requires a `metadata:read` scope. CLAUDE.md's documented scope set names neither — the Javadoc/README assert a scope contract the project's own auth documentation does not corroborate.
|
||||
|
||||
**Recommendation:** Reconcile the scope names with `src/MxGateway.Server/Security/` and CLAUDE.md; correct the Javadoc/README to the actual scope strings, or fix CLAUDE.md if sub-scopes were genuinely added.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Partially re-triaged. Verified against `src/MxGateway.Server/Security/Authorization/GatewayScopes.cs` and `GatewayGrpcScopeResolver.cs`: the canonical scope catalog is `session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`. (a) The README's `metadata:read` for the Galaxy Repository is **correct** — `TestConnectionRequest`/`GetLastDeployTimeRequest`/`DiscoverHierarchyRequest`/`WatchDeployEventsRequest` all resolve to `GatewayScopes.MetadataRead`; no change needed. CLAUDE.md's prose lists only coarse scope groups, but the canonical resolver does define `metadata:read`. (b) The `acknowledgeAlarm` Javadoc's `invoke:alarm-ack` is **wrong** — no such scope exists. `AcknowledgeAlarmRequest` and `QueryActiveAlarmsRequest` are not special-cased in `GatewayGrpcScopeResolver`, so they fall through the `_ => GatewayScopes.Admin` default and require the `admin` scope. The Javadoc was corrected to state the `admin` scope; `queryActiveAlarms` did not assert a scope and was left unchanged. The README does not mention alarms, so no README change was required.
|
||||
|
||||
### Client.Java-011
|
||||
|
||||
@@ -183,13 +183,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Performance & resource management |
|
||||
| Location | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxEventStream.java:37-63` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The event stream relies on default gRPC auto-inbound flow control: the async stub auto-requests messages, so the server can push faster than the 16-element bounded queue drains. A momentarily slow consumer triggers queue overflow and an immediate stream-fault cancel. This is consistent with the documented fail-fast event-backpressure design, but the client never applies real flow control, so even brief consumer stalls kill the subscription.
|
||||
|
||||
**Recommendation:** Confirm fail-fast is intended (it appears to be); if so, document it on `MxEventStream` so callers know a slow consumer terminates the stream. Optionally expose the queue capacity or opt-in flow control.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Confirmed fail-fast is intended — CLAUDE.md ("fail-fast event backpressure") and `docs/DesignDecisions.md` make a slow consumer losing its subscription a deliberate v1 design choice, so this is documentation-only, not a behavior bug. Added an explicit "Backpressure (fail-fast)" section to the `MxEventStream` class Javadoc explaining that the adaptor uses gRPC auto-inbound flow control with a fixed 16-element buffer and no client flow control, that a consumer stall long enough to fill the buffer triggers an overflow that cancels the subscription and surfaces an `MxGatewayException`, and that consumers must drain promptly and be ready to resubscribe with a resume cursor. `clients/java/README.md` carries the same caveat. The queue capacity was intentionally left non-configurable to keep the v1 surface aligned with the gateway design; overflow behavior is covered by `MxGatewayLowFindingsTests.eventStreamQueueOverflowSurfacesExceptionFromNext`.
|
||||
|
||||
### Client.Java-012
|
||||
|
||||
@@ -198,10 +198,10 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `clients/java/mxgateway-cli/src/main/java/com/dohertylan/mxgateway/cli/MxGatewayCli.java:667-674` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `CommonOptions.resolved()` mutates `this` (`resolvedApiKey`, `resolvedTimeout`) and returns `this`, but `toClientOptions()` and `redactedJsonMap()` read those mutated fields. If `redactedJsonMap()` is ever called before `resolved()`, it silently emits empty-string defaults. The "return this after mutating" pattern is fragile and surprising.
|
||||
|
||||
**Recommendation:** Make `resolved()` return an immutable resolved value object, or compute `resolvedApiKey`/`resolvedTimeout` lazily in their getters so call ordering cannot produce stale output.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** (2026-05-18) Confirmed against source: `resolved()` populated the `resolvedApiKey`/`resolvedTimeout` mutable fields and `toClientOptions()`/`redactedJsonMap()` read them, so calling either before `resolved()` emitted stale empty/30s defaults. The two mutable fields were removed and replaced with side-effect-free accessor methods `resolvedApiKey()` and `resolvedTimeout()` that compute their value on each call (API key from `--api-key` or the `--api-key-env` variable; timeout via `parseDuration`). `toClientOptions()` and `redactedJsonMap()` now call those accessors directly, so call ordering can no longer produce stale output. `resolved()` is retained as a no-op returning `this` purely for call-site readability (`common.resolved()`), with its Javadoc updated to state resolution is now lazy. Pure-refactor with no runtime-behavior change for the existing call order, so no new test was added; covered by the existing `MxGatewayCliTests` JSON-redaction and option-parsing tests.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-18 |
|
||||
| Commit reviewed | `3cc53a8` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 9 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -33,13 +33,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `clients/python/pyproject.toml:8,25`, `clients/python/src/mxgateway_cli/commands.py:25` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The package `description` in `pyproject.toml` still says "Async Python client *scaffold*" even though the client is fully implemented. Stale "scaffold" wording misrepresents maturity to anyone reading PyPI metadata. (The `mxgw-py` console-script name is itself consistent between `pyproject.toml` and the README.)
|
||||
|
||||
**Recommendation:** Update the `pyproject.toml` description to drop "scaffold"; keep README CLI examples in sync with the actual `mxgw-py` entry point.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Confirmed: `pyproject.toml:8` `description` read "Async Python client scaffold for MXAccess Gateway." Changed to "Async Python client for MXAccess Gateway." The `mxgw-py` console-script name was already consistent with the README, so no README change was needed. Pure metadata fix — no test required.
|
||||
|
||||
### Client.Python-002
|
||||
|
||||
@@ -48,13 +48,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `clients/python/src/mxgateway/__init__.py:27` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `MxGatewayCommandError` is imported into `__init__.py` and is a documented public exception, but it is missing from `__all__`. It is the parent of `MxAccessError` and a meaningful catch target, so omitting it from the public surface is inconsistent — `from mxgateway import *` will not expose it and tooling that respects `__all__` treats it as private.
|
||||
|
||||
**Recommendation:** Add `"MxGatewayCommandError"` to the `__all__` list.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Re-triaged: this finding is stale against the reviewed source. `clients/python/src/mxgateway/__init__.py` already imports `MxGatewayCommandError` (line 16) **and** lists `"MxGatewayCommandError"` in `__all__` (line 38). `from mxgateway import *` exposes it correctly. Verified at runtime (`'MxGatewayCommandError' in mxgateway.__all__` is `True`). No source change required — the defect described no longer exists.
|
||||
|
||||
### Client.Python-003
|
||||
|
||||
@@ -78,13 +78,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `clients/python/src/mxgateway_cli/commands.py:386,402-404` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** In `_smoke`, the local variable `closed` is set to `False` and never reassigned; the `finally` block's `if not closed:` is therefore always true. This is dead/misleading code suggesting a removed early-close path.
|
||||
|
||||
**Recommendation:** Remove the `closed` variable and the `if not closed:` guard; call `await session.close()` directly in the `finally` block (or use `async with session:`).
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Confirmed: `closed = False` was set and never reassigned, making `if not closed:` dead code. Replaced the `try/finally` with `async with session:` so the session is closed via the documented async context manager — `Session` already implements `__aexit__` → `close()`. Behaviour is unchanged (the session is still closed on every exit path); no test needed for the dead-code removal — exercised by the existing CLI smoke test.
|
||||
|
||||
### Client.Python-005
|
||||
|
||||
@@ -108,13 +108,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `clients/python/src/mxgateway/client.py:74-82`, `clients/python/src/mxgateway/galaxy.py:85-93`, `clients/python/src/mxgateway/session.py:38-55` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `close()` on the clients and `Session.close()` use a plain `self._closed` check-then-set with an `await` between, with no lock. If two coroutines call `close()` concurrently both can pass the guard before either sets it, causing a double `channel.close()` / double `CloseSession` RPC. Single-task usage is the documented contract, so impact is low, but the idempotency guarantee asserted in docstrings only holds for sequential calls.
|
||||
|
||||
**Recommendation:** Set `self._closed = True` before the `await`, or guard with an `asyncio.Lock`, so the idempotency claim holds under concurrent close.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Confirmed the check-then-set window. Fixed `GatewayClient.close`, `GalaxyRepositoryClient.close`, and `Session.close` to set `self._closed = True` *before* the `await` (channel close / `CloseSession` RPC). A second coroutine entering `close()` while the first is still awaiting now hits the early-return guard and does not issue a second `channel.close()` / `CloseSession`. Docstrings updated to state the idempotency holds under concurrent calls. TDD: regression tests in `tests/test_low_severity_findings.py` (`test_gateway_client_concurrent_close_closes_channel_once`, `test_galaxy_client_concurrent_close_closes_channel_once`, `test_session_concurrent_close_sends_one_close_session_rpc`) — each uses a fake channel/client that stalls inside `close`/`close_session_raw` so two concurrent `close()` calls interleave at the exact race window; they failed before the fix and pass after.
|
||||
|
||||
### Client.Python-007
|
||||
|
||||
@@ -123,13 +123,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `clients/python/src/mxgateway/client.py:204-213` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `_canceling_iterator` (gateway event stream) does not catch `asyncio.CancelledError` to invoke `call.cancel()` explicitly — it relies on the `finally` block. `galaxy.py:_canceling_iterator` *does* explicitly catch `CancelledError`, cancel, and re-raise. The two are functionally equivalent today, but the inconsistency between near-identical helpers invites future divergence.
|
||||
|
||||
**Recommendation:** Make the two `_canceling_iterator` helpers identical, ideally by factoring a single shared helper.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Confirmed the divergence. Factored a single shared helper: `client._canceling_iterator(call, operation)` now takes the `map_rpc_error` operation string as a parameter, explicitly catches `asyncio.CancelledError` (cancels the call, re-raises) and `grpc.RpcError`, and repeats the cancel in `finally`. This replaces both the gateway `_canceling_iterator` and the gateway `_canceling_active_alarms_iterator`; `galaxy.py` now imports and delegates to the same helper instead of defining its own, so the gateway and Galaxy stream helpers are byte-for-byte identical. TDD: `tests/test_low_severity_findings.py::test_gateway_stream_iterator_cancels_call_on_task_cancellation` drives a cancellable fake stream and asserts the gateway iterator cancels the underlying call on task cancellation. All existing stream-cancellation tests still pass.
|
||||
|
||||
### Client.Python-008
|
||||
|
||||
@@ -138,13 +138,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `clients/python/src/mxgateway/values.py:62-67,83-88` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `to_mx_value` maps any Python `float` to `VT_R8`/`MX_DATA_TYPE_DOUBLE` with no handling for `nan`/`inf`, which are serialised and forwarded to MXAccess which may reject or mis-handle them. `bytes` is mapped to `VT_RECORD`/`MX_DATA_TYPE_UNKNOWN`, a questionable default. The `data_type` keyword exists but `Session.write` never forwards it.
|
||||
|
||||
**Recommendation:** Document the float/bytes mapping assumptions, optionally validate finiteness, and consider plumbing the `data_type` keyword through `Session.write`/`write2`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Confirmed the non-finite-float hazard. Added an `_ensure_finite` guard in `values.py`: `to_mx_value` now raises `ValueError` for `nan`/`inf`/`-inf`, both for a scalar `float` and for a non-finite element inside a float sequence — MXAccess has no defined wire representation for non-finite doubles, so rejecting client-side is the correct fail-fast. The `float`/`bytes` mapping assumptions (finite-only doubles; `bytes` as an opaque `VT_RECORD` pass-through) are now documented in the `values.py` module docstring and `clients/python/README.md`. Plumbing `data_type` through `Session.write`/`write2` was deliberately *not* done: it is a larger public-API surface change the finding only marks as "consider", and the documented MXAccess-parity convention is type-by-Python-value; the `data_type` keyword stays available on `to_mx_value` for callers that build the `MxValue` directly. TDD: `tests/test_low_severity_findings.py` adds `test_to_mx_value_rejects_nan`, `test_to_mx_value_rejects_positive_infinity`, `test_to_mx_value_rejects_negative_infinity`, `test_to_mx_value_rejects_non_finite_float_in_sequence`, and `test_to_mx_value_accepts_finite_float`. README updated since `to_mx_value` (used by `Session.write`/`write2`) now rejects an input it previously accepted.
|
||||
|
||||
### Client.Python-009
|
||||
|
||||
@@ -168,13 +168,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `clients/python/src/mxgateway/session.py:404`, `clients/python/src/mxgateway_cli/commands.py:422-425` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `session.py` ends with a module-level late import `from .client import GatewayClient # noqa: E402` purely to satisfy a string type hint, and `commands.py:_session` does a function-local import. Both work around a circular dependency that `from __future__ import annotations` (already in effect) makes unnecessary. `_session` also lacks a return type annotation.
|
||||
|
||||
**Recommendation:** Drop the runtime late import in `session.py` and use a `TYPE_CHECKING`-guarded import for the hint; add the `-> Session` return annotation to `commands.py:_session`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Confirmed: with `from __future__ import annotations` in effect all annotations are strings, so the runtime late import was unnecessary. Removed the trailing `from .client import GatewayClient # noqa: E402` in `session.py` and replaced it with a top-of-file `if TYPE_CHECKING:` import that satisfies the `GatewayClient` hint without a runtime dependency (no import cycle: `client.py` does not import `session` at module scope). In `commands.py`, hoisted the function-local `from mxgateway.session import Session` to a module-level import and added the `-> Session` return annotation to `_session`. Verified `import mxgateway` and `import mxgateway_cli.commands` succeed with no circular-import error. Pure refactor — covered by the existing import and CLI tests; no new test needed.
|
||||
|
||||
### Client.Python-011
|
||||
|
||||
@@ -183,13 +183,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `clients/python/src/mxgateway/errors.py:122-148` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `ensure_mxaccess_success` raises `MxAccessError` if any `mx_status.success == 0`. This treats `success == 0` as the failure sentinel, but `0` is also the proto3 scalar default for an unset `MxStatusProxy`. If the gateway ever returns a reply with an unpopulated status entry (e.g. a partially-filled bulk result), the client raises `MxAccessError` even though no real failure occurred.
|
||||
|
||||
**Recommendation:** Confirm against the proto/gateway contract whether `success` is guaranteed populated for every `statuses` entry; if not, key the failure decision on an explicit failure field rather than the `success == 0` default.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Confirmed against the gateway contract: `success` is **not** guaranteed populated for every `statuses` entry. `src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs::ConvertMany` emits a placeholder `MxStatusProxy` for a null `MXSTATUS_PROXY` COM array entry, setting `Category`/`DetectedBy` to `Unknown` but **leaving `Success` at its proto3 default of 0**. A fully-default proto entry likewise has `success == 0`. Under the old client logic either placeholder would falsely raise `MxAccessError`. Fixed `ensure_mxaccess_success` to key the per-status failure decision on a new `_is_mxaccess_status_failure` helper that requires `success == 0` **and** a populated, non-OK `category` — a status with `category` of `MX_STATUS_CATEGORY_UNSPECIFIED` (default proto) or `MX_STATUS_CATEGORY_UNKNOWN` (the null-entry placeholder) is treated as unpopulated and ignored. `MX_STATUS_CATEGORY_OK` is also excluded so a genuine success entry never raises. Real failures (categories `WARNING` and the error categories, raw value ≥ 2) still raise as before — the existing `write.mxaccess-failure` fixture (`SECURITY_ERROR`/`OPERATIONAL_ERROR` statuses) and the `MXACCESS_FAILURE` protocol-status path are unaffected. TDD: `tests/test_low_severity_findings.py` adds `test_ensure_mxaccess_success_ignores_unpopulated_status_entry` (default + null-placeholder entries, no raise), `test_ensure_mxaccess_success_raises_on_populated_failure_status` (populated `COMMUNICATION_ERROR`, raises), and `test_ensure_mxaccess_success_passes_when_status_reports_success`. No public-behaviour change for genuine replies, so no README update.
|
||||
|
||||
### Client.Python-012
|
||||
|
||||
@@ -198,10 +198,10 @@
|
||||
| Severity | Low |
|
||||
| Category | mxaccessgw conventions |
|
||||
| Location | `clients/python/src/mxgateway/client.py:84-108`, `clients/python/src/mxgateway/session.py:57-77` |
|
||||
| Status | Open |
|
||||
| Status | Won't Fix |
|
||||
|
||||
**Description:** `Session.invoke_raw` does not run `ensure_mxaccess_success` while `Session.invoke` does, so a caller using `invoke_raw` for parity tests gets a reply where an MXAccess HRESULT failure is silently embedded with no exception. This is by design but under-documented — the README's "preserve raw replies" sentence does not state that `*_raw` methods skip MXAccess-failure detection entirely.
|
||||
|
||||
**Recommendation:** Document explicitly (README + docstring) that `*_raw` methods surface MXAccess HRESULT/status failures only inside the reply and do not raise `MxAccessError`, so parity-test callers know to inspect `protocol_status`/`hresult`/`statuses` themselves.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Won't Fix (no behaviour change). Confirmed this is intentional, correct parity behaviour: the `*_raw` methods exist precisely so parity-test callers can inspect an unmodified gateway reply, including embedded MXAccess HRESULT/status failures, without an exception masking them. Changing `invoke_raw` to raise `MxAccessError` would defeat its purpose and duplicate `Session.invoke`. The finding's only actionable point is the documentation gap, which has been addressed: `clients/python/README.md` now states explicitly that `*_raw` methods enforce gateway protocol success only and do **not** run MXAccess-failure detection, and the docstrings of `GatewayClient.invoke_raw` and `Session.invoke_raw` say the same and point callers to inspect `protocol_status`/`hresult`/`statuses` (and to `Session.invoke` for the checked variant). No code/test change — the runtime contract is unchanged and correct.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-18 |
|
||||
| Commit reviewed | `6c64030` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 7 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
| 5 | Security | Credential-sensitive fields are clearly commented; no secrets forced into loggable shapes. No issues found. |
|
||||
| 6 | Performance & resource management | `DiscoverHierarchy` is paged; alarm-snapshot streams are server-streamed; no bloat issues. No issues found. |
|
||||
| 7 | Design-document adherence | `.proto` files match design intent but `docs/Grpc.md` is stale (Contracts-001); worker vs public alarm-status shapes unreconciled in docs (Contracts-008). |
|
||||
| 8 | Code organization & conventions | Package/file layout correct; `mxaccess_worker.proto` Protobuf item missing `ProtoRoot` (Contracts-003); stale class summary (Contracts-004). |
|
||||
| 8 | Code organization & conventions | Package/file layout correct; stale class summary (Contracts-004). Contracts-003 (`mxaccess_worker.proto` Protobuf item missing `ProtoRoot`) was re-triaged as not-a-defect — the attribute is already present. |
|
||||
| 9 | Testing coverage | Gateway/worker/alarm round-trips covered; Galaxy Repository protos and raw `MxArray` paths untested (Contracts-007). |
|
||||
| 10 | Documentation & comments | Proto comments accurate and domain-rich; one stale class summary (Contracts-004). |
|
||||
|
||||
@@ -33,13 +33,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Design-document adherence |
|
||||
| Location | `docs/Grpc.md:13` (and `:3`, `:32`, `:39`) |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `mxaccess_gateway.proto` now declares six RPCs on `MxAccessGateway` (`OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, `QueryActiveAlarms`). `docs/Grpc.md` still describes "the four `MxAccessGateway` RPCs" in its type table and omits `AcknowledgeAlarm`/`QueryActiveAlarms` from the Validation Rules table. CLAUDE.md requires docs to change in the same commit as the contract; the alarm RPC commits left this doc stale and misleading about the public surface.
|
||||
|
||||
**Recommendation:** Update `docs/Grpc.md` to enumerate all six RPCs and add `AcknowledgeAlarm`/`QueryActiveAlarms` to the type/handler and validation tables, or explicitly cross-reference `AlarmClientDiscovery.md`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** _(2026-05-18)_ Confirmed against `mxaccess_gateway.proto` — six RPCs declared, doc said "four". Updated `docs/Grpc.md`: the collaborator table now says "six `MxAccessGateway` RPCs", the RPC Handlers intro enumerates all six, added dedicated `AcknowledgeAlarm` and `QueryActiveAlarms` handler subsections (noting the alarm surface routes through `IAlarmRpcDispatcher` and is validated inline rather than via `MxAccessGrpcRequestValidator`, with a cross-reference to `AlarmClientDiscovery.md`), and added both alarm RPCs to the Validation Rules table.
|
||||
|
||||
### Contracts-002
|
||||
|
||||
@@ -63,13 +63,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `src/MxGateway.Contracts/MxGateway.Contracts.csproj:10` |
|
||||
| Status | Open |
|
||||
| Status | Won't Fix |
|
||||
|
||||
**Description:** The `<Protobuf>` item for `mxaccess_worker.proto` omits `ProtoRoot="Protos"`, while the items for `mxaccess_gateway.proto` (line 9) and `galaxy_repository.proto` (line 11) both set it. `mxaccess_worker.proto` does `import "mxaccess_gateway.proto"`, which resolves only because Grpc.Tools adds the importing file's own directory to the proto path. The inconsistency is fragile — tooling changes to ProtoRoot handling could break import resolution.
|
||||
|
||||
**Recommendation:** Add `ProtoRoot="Protos"` to the `mxaccess_worker.proto` `<Protobuf>` item so all three entries are consistent.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** _(2026-05-18)_ Re-triaged as not-a-defect: the finding's premise is factually wrong. Line 10 of `MxGateway.Contracts.csproj` already carries `ProtoRoot="Protos"` — all three `<Protobuf>` items are already consistent. `git show 6c64030:src/MxGateway.Contracts/MxGateway.Contracts.csproj` (the reviewed commit) confirms the attribute was present at review time too; the csproj has not been touched since `133c830`. No code change made. Status set to Won't Fix because there is nothing to fix.
|
||||
|
||||
### Contracts-004
|
||||
|
||||
@@ -78,13 +78,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/MxGateway.Contracts/GatewayContractInfo.cs:3-6` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The XML summary says the class exposes version metadata "before generated protobuf contracts are introduced." Generated protobuf contracts have long been introduced and are consumed across the solution. The comment is stale; the class now holds the authoritative `GatewayProtocolVersion`/`WorkerProtocolVersion` advertised in `OpenSessionReply` and used to validate `WorkerEnvelope` framing.
|
||||
|
||||
**Recommendation:** Reword the summary to describe the current purpose — version constants advertised in `OpenSessionReply` and used to validate `WorkerEnvelope` protocol framing.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** _(2026-05-18)_ Confirmed stale — the class is consumed by `GatewayApplication`/`OpenSessionReply` and `WorkerEnvelope` framing checks across the solution. Reworded the XML summary on `GatewayContractInfo` to describe the actual current purpose: `GatewayProtocolVersion` is advertised to clients in `OpenSessionReply`, and `WorkerProtocolVersion` validates `WorkerEnvelope` protocol framing on the gateway↔worker pipe.
|
||||
|
||||
### Contracts-005
|
||||
|
||||
@@ -93,13 +93,13 @@
|
||||
| Severity | Low |
|
||||
| Category | mxaccessgw conventions |
|
||||
| Location | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto`, `src/MxGateway.Contracts/Protos/mxaccess_worker.proto` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The ProtobufStyleGuide mandates reserving removed field numbers / enum values. Evolution to date has been purely additive, so this is not a current violation — but none of the `.proto` files contain any `reserved` declarations, leaving no in-file guardrail for the first removal. This is a latent maintainability gap.
|
||||
|
||||
**Recommendation:** When any field or enum value is eventually removed, add a `reserved` range/name in the same change. Consider a short comment block in each message documenting the policy so future editors apply `reserved` rather than reusing tags.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** _(2026-05-18)_ Confirmed: no field or enum value has ever been removed, so adding `reserved` ranges now would be incorrect (there are no retired tags to reserve, and inventing ranges for never-used numbers would itself violate the contract). Took the finding's least-invasive option — added a short wire-compatibility policy comment block at the top of all three `.proto` files (`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`) stating the additive-only rule and instructing future editors to add a `reserved` range + name in the same change as any removal. Comment-only, no wire-format or generated-type change. The `reserved` declarations themselves remain correctly deferred to the first actual removal.
|
||||
|
||||
### Contracts-006
|
||||
|
||||
@@ -108,13 +108,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto:647` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `MxStatusProxy.success` is declared `int32 success = 1` with no comment. The name reads like a boolean flag but the type is a 32-bit integer (mirroring MXAccess `MXSTATUS_PROXY`, which stores a numeric success/HResult-like value). Without a comment a client author can reasonably misinterpret the field (treat non-1 as failure, or expect only 0/1).
|
||||
|
||||
**Recommendation:** Add a comment clarifying the semantic — what range of values it carries and how 0 vs non-zero map to MXAccess status — per the style guide rule to comment fields carrying raw MXAccess status detail.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** _(2026-05-18)_ Confirmed: `int32 success = 1` had no comment. Cross-checked against the worker `MxStatusProxyConverter`, which reads the COM struct's `success` field verbatim (a 16-bit signed value) without reinterpretation, and against the MXAccess analysis (`MXAccess-Public-API.md`: `MxStatus`/`MXSTATUS_PROXY` are identical structs with a `short success` member). Added a field comment to `MxStatusProxy.success` stating it mirrors the COM struct's numeric `success` member (NOT a boolean), is carried verbatim for diagnostics, and that clients should branch on `category` (`MX_STATUS_CATEGORY_OK` marks success) — deliberately avoiding an over-specified 0-vs-1 claim, since the gateway never maps `success` to an outcome and `category` is the authoritative field. Comment-only change.
|
||||
|
||||
### Contracts-007
|
||||
|
||||
@@ -123,13 +123,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Location | `src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `ProtobufContractRoundTripTests` covers gateway command/reply/event, alarm transition, alarm ack request/reply, active-alarm snapshot, and the worker envelope. It has no coverage for: (a) any `galaxy_repository.proto` message (`DiscoverHierarchy*`, `GalaxyObject`, `GalaxyAttribute`, `DeployEvent`, the `root` oneof, wrapper-typed fields); (b) `BulkSubscribeReply`/`SubscribeResult` and the bulk command kinds; (c) `MxValue`/`MxArray` `raw_value`/`RawArray` (`bytes`) paths and the `WorkerFault`/`WorkerHeartbeat` IPC bodies.
|
||||
|
||||
**Recommendation:** Add round-trip tests for the Galaxy Repository messages (including the `root` oneof and proto wrapper fields), the bulk-subscribe reply, and the remaining `WorkerEnvelope` body cases.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** _(2026-05-18)_ Confirmed the listed gaps and added round-trip tests to `ProtobufContractRoundTripTests` covering all three areas: (a) Galaxy Repository — `GalaxyRepositoryDescriptor_ContainsBrowseServiceMethods`, `DiscoverHierarchyRequest_RoundTripsRootOneofAndWrapperFields` (a `[Theory]` exercising all three `root` oneof arms plus the `Int32Value` wrapper `max_depth`), `DiscoverHierarchyReply_RoundTripsObjectAndAttributeGraph`, `DeployEvent_RoundTripsTimestampAndCounters`, `GalaxyConnectionReplies_RoundTrip`; (b) `BulkSubscribeReply_RoundTripsSubscribeResults` and `MxCommandReply_RoundTripsBulkSubscribePayload` (bulk-subscribe command kind + payload case); (c) `MxValue_RoundTripsRawValueBytesPayload`, `MxArray_RoundTripsRawArrayPayload`, `WorkerEnvelope_RoundTripsWorkerFaultBody`, `WorkerEnvelope_RoundTripsWorkerHeartbeatBody`. All new tests pass; the full `ProtobufContractRoundTripTests` class is 27 tests green.
|
||||
|
||||
### Contracts-008
|
||||
|
||||
@@ -138,10 +138,10 @@
|
||||
| Severity | Low |
|
||||
| Category | Design-document adherence |
|
||||
| Location | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto:451-459`, `:627-636` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The worker-side `AcknowledgeAlarmReplyPayload` carries the alarm-ack outcome as `int32 native_status`, while the public `AcknowledgeAlarmReply` carries it as `MxStatusProxy status` plus `optional int32 hresult`. The comment explains the worker echoes `native_status` into `AcknowledgeAlarmReply.hresult`, but the two outcome shapes (raw `int32` vs structured `MxStatusProxy`) are not reconciled in `docs/Contracts.md` / `AlarmClientDiscovery.md`. A reader cannot tell whether `MxStatusProxy status` is always populated or only on COM-layer failure.
|
||||
|
||||
**Recommendation:** Document in `docs/Contracts.md` (or `AlarmClientDiscovery.md`) how the worker `native_status` maps onto the public reply's `status`/`hresult` pair so client authors know which field is authoritative.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** _(2026-05-18)_ Verified against `WorkerAlarmRpcDispatcher.AcknowledgeAsync`. The asymmetry is larger than the finding implies: the dispatcher copies the worker `MxCommandReply.hresult` into `AcknowledgeAlarmReply.hresult` but **never** assigns `AcknowledgeAlarmReply.status` — the `MxStatusProxy status` field is left UNSET on every reply. The proto comment on `status` ("Native MxAccess status describing the outcome of the ack") was therefore actively misleading. Fixed: (1) reworded the `mxaccess_gateway.proto` comments on `AcknowledgeAlarmReply.hresult` (now identifies it as the authoritative native-return-code field) and `AcknowledgeAlarmReply.status` (now states it is reserved/unset and clients must not depend on it); (2) extended `docs/AlarmClientDiscovery.md` section 4 with a "Worker `native_status` → public `AcknowledgeAlarmReply` mapping" subsection spelling out that `hresult` is authoritative (`0` = success) and `status` is always unset, and that clients should branch on `protocol_status` then `hresult`, never `status`.
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
| Review date | 2026-05-18 |
|
||||
| Commit reviewed | `6c64030` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 4 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Result |
|
||||
|---|---|---|
|
||||
| 1 | Correctness & logic bugs | Issues found: IntegrationTests-003 (asserts only on first event), IntegrationTests-010 (`WaitForFirstMessageAsync` ignores cancellation). |
|
||||
| 1 | Correctness & logic bugs | Issues found: IntegrationTests-003 (asserts only on first event), IntegrationTests-010 (`WaitForMessageAsync` ignores cancellation). |
|
||||
| 2 | mxaccessgw conventions | Live tests correctly gated and skip (not fail) when prerequisites are absent; `LiveGalaxyRepositoryFactAttribute` undocumented in the opt-in matrix. |
|
||||
| 3 | Concurrency & thread safety | Issue found: IntegrationTests-007 (no `[Collection]`/parallelism guard for shared MXAccess/ZB/GLAuth). |
|
||||
| 4 | Error handling & resilience | Issue found: IntegrationTests-004 (cleanup `WaitAsync` can mask the original failure). |
|
||||
@@ -123,13 +123,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs:20`, `src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs:5`, `src/MxGateway.IntegrationTests/DashboardLdapLiveTests.cs:9` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The live test classes contend for genuinely shared singletons — one MXAccess COM provider, one ZB SQL database, one GLAuth instance with a 3-fail/10-minute per-IP lockout. No `[Collection]` annotation or `DisableTestParallelization` is declared, so xUnit's default cross-class parallelism could run the Galaxy tests concurrently or interleave an LDAP failure burst that trips the GLAuth lockout.
|
||||
|
||||
**Recommendation:** Place the live test classes in a shared `[Collection]`, or set `[assembly: CollectionBehavior(DisableTestParallelization = true)]` for this opt-in project, so live external resources are accessed serially.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: Confirmed — no `[Collection]` or assembly-level `CollectionBehavior` existed. Added `LiveResourcesCollection.cs` with a `[CollectionDefinition(Name, DisableParallelization = true)]` and applied `[Collection(LiveResourcesCollection.Name)]` to `WorkerLiveMxAccessSmokeTests`, `GalaxyRepositoryLiveTests`, and `DashboardLdapLiveTests`. A named collection (rather than an assembly-wide `DisableTestParallelization`) was chosen so the live classes serialize against each other and within themselves while non-live tests (`IntegrationTestEnvironmentTests`) keep parallelizing. Verified by build; live tests not executed (no MXAccess COM / live LDAP in this environment).
|
||||
|
||||
### IntegrationTests-008
|
||||
|
||||
@@ -138,13 +138,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `src/MxGateway.IntegrationTests/LiveLdapFactAttribute.cs`, `src/MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs`, `src/MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Three near-identical fact attributes each re-implement the same "compare env var to `1` with `StringComparison.Ordinal`, set `Skip` otherwise" pattern. `LiveMxAccessFactAttribute` delegates to `IntegrationTestEnvironment` while the other two inline the logic, so the project has two divergent styles for the same concern.
|
||||
|
||||
**Recommendation:** Extract a shared helper (e.g. `IntegrationTestEnvironment.IsEnabled(string variableName)`) and have all three attributes call it.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: Confirmed — `LiveLdapFactAttribute.Enabled` and `LiveGalaxyRepositoryFactAttribute.Enabled` each inlined the ordinal `== "1"` comparison while `LiveMxAccessFactAttribute` delegated to `IntegrationTestEnvironment`. Added `IntegrationTestEnvironment.IsEnabled(string variableName)` as the single implementation; `LiveMxAccessTestsEnabled`, `LiveLdapFactAttribute.Enabled`, and `LiveGalaxyRepositoryFactAttribute.Enabled` now all call it. Verified by build.
|
||||
|
||||
### IntegrationTests-009
|
||||
|
||||
@@ -153,13 +153,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs:372-375` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `TestServerCallContext` is XML-documented as a "Mock server call context," but it is a hand-written stub/fake with no mocking framework and no verification behavior. Per the style guides (accurate naming; explain why not what), calling it a mock misleads readers who may expect verifiable interactions.
|
||||
|
||||
**Recommendation:** Reword the summary to "test stub" / "minimal `ServerCallContext` implementation for in-process gRPC calls."
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: Confirmed — the summary read "Mock server call context for testing gRPC calls." Reworded to "Minimal `ServerCallContext` stub for invoking the gRPC service in-process," noting it is a hand-written fake with no verification behavior. No mocking framework is involved; this is a documentation-only fix. Verified by build.
|
||||
|
||||
### IntegrationTests-010
|
||||
|
||||
@@ -168,10 +168,12 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs:366-369` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `WaitForFirstMessageAsync` accepts only a `timeout` and never observes a `CancellationToken`. There is no per-test cancellation propagation, so if the gateway/worker hangs without writing an event the test relies solely on the 15s `WaitAsync` timeout and gives no contextual diagnostics. Combined with IntegrationTests-004, a hung live worker produces a bare `TimeoutException`.
|
||||
|
||||
**Recommendation:** Accept a `CancellationToken` (linked to `TestServerCallContext`'s token), pass it to `firstMessage.Task.WaitAsync(timeout, token)`, and on timeout emit the recorded `Messages` count via `output.WriteLine` before throwing.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Re-triage:** The named method `WaitForFirstMessageAsync` no longer exists — IntegrationTests-003's resolution renamed/replaced it with `RecordingServerStreamWriter.WaitForMessageAsync(predicate, timeout)`, which scans recorded messages and blocks on a `SemaphoreSlim`. The underlying defect still held: that replacement method also took only a `timeout` and never observed a `CancellationToken`. The finding remains valid (Low, Correctness) against the renamed method; the recommendation's `firstMessage.Task.WaitAsync` detail is stale but the intent (thread a token, surface a count on timeout) is unchanged.
|
||||
|
||||
**Resolution:** Resolved 2026-05-18: Added an optional `CancellationToken` parameter to `WaitForMessageAsync`, linked with the existing timeout source via `CancellationTokenSource.CreateLinkedTokenSource`, so a per-test cancellation aborts the wait promptly. `GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses` now creates a `CancellationTokenSource`, passes its token into the `StreamEvents` `TestServerCallContext` and into `WaitForMessageAsync`, so the stream call and the wait share one cancellation source. On timeout the method already throws a `TimeoutException` whose message includes the scanned message count, satisfying the "emit recorded count" intent (the count surfaces in the test failure rather than via a separate `output.WriteLine`). Verified by build; live tests not executed.
|
||||
|
||||
+79
-80
@@ -10,92 +10,23 @@ Each module's `findings.md` is the source of truth; this file is generated from
|
||||
|
||||
| Module | Reviewer | Date | Commit | Status | Open | Total |
|
||||
|---|---|---|---|---|---|---|
|
||||
| [Client.Dotnet](Client.Dotnet/findings.md) | Claude Code | 2026-05-18 | `3cc53a8` | Reviewed | 5 | 8 |
|
||||
| [Client.Go](Client.Go/findings.md) | Claude Code | 2026-05-18 | `3cc53a8` | Reviewed | 7 | 10 |
|
||||
| [Client.Java](Client.Java/findings.md) | Claude Code | 2026-05-18 | `3cc53a8` | Reviewed | 7 | 12 |
|
||||
| [Client.Python](Client.Python/findings.md) | Claude Code | 2026-05-18 | `3cc53a8` | Reviewed | 9 | 12 |
|
||||
| [Client.Dotnet](Client.Dotnet/findings.md) | Claude Code | 2026-05-18 | `3cc53a8` | Reviewed | 0 | 8 |
|
||||
| [Client.Go](Client.Go/findings.md) | Claude Code | 2026-05-18 | `3cc53a8` | Reviewed | 0 | 10 |
|
||||
| [Client.Java](Client.Java/findings.md) | Claude Code | 2026-05-18 | `3cc53a8` | Reviewed | 0 | 12 |
|
||||
| [Client.Python](Client.Python/findings.md) | Claude Code | 2026-05-18 | `3cc53a8` | Reviewed | 0 | 12 |
|
||||
| [Client.Rust](Client.Rust/findings.md) | Claude Code | 2026-05-18 | `3cc53a8` | Reviewed | 0 | 12 |
|
||||
| [Contracts](Contracts/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 7 | 8 |
|
||||
| [IntegrationTests](IntegrationTests/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 4 | 10 |
|
||||
| [Server](Server/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 8 | 14 |
|
||||
| [Tests](Tests/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 6 | 12 |
|
||||
| [Worker](Worker/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 7 | 15 |
|
||||
| [Worker.Tests](Worker.Tests/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 8 | 15 |
|
||||
| [Contracts](Contracts/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 0 | 8 |
|
||||
| [IntegrationTests](IntegrationTests/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 0 | 10 |
|
||||
| [Server](Server/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 0 | 14 |
|
||||
| [Tests](Tests/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 0 | 12 |
|
||||
| [Worker](Worker/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 0 | 15 |
|
||||
| [Worker.Tests](Worker.Tests/findings.md) | Claude Code | 2026-05-18 | `6c64030` | Reviewed | 0 | 15 |
|
||||
|
||||
## Pending findings
|
||||
|
||||
Findings with status `Open` or `In Progress`, ordered by severity.
|
||||
|
||||
| ID | Severity | Category | Location | Description |
|
||||
|---|---|---|---|---|
|
||||
| Client.Dotnet-004 | Low | Error handling & resilience | `clients/dotnet/MxGateway.Client/MxGatewayClient.cs:283-294`, `clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs:392-403` | `ExecuteSafeUnaryAsync` wraps the whole Polly retry pipeline in a single linked CTS cancelled after `Options.DefaultCallTimeout`, while `CreateCallOptions` also stamps each individual call with a `DefaultCallTimeout` gRPC deadline. The ret… |
|
||||
| Client.Dotnet-005 | Low | Correctness & logic bugs | `clients/dotnet/MxGateway.Client/MxGatewaySession.cs:82,124,175` | `RegisterAsync`/`AddItemAsync`/`AddItem2Async` return `reply.<Typed>?.ServerHandle ?? reply.ReturnValue.Int32Value`. After `EnsureMxAccessSuccess()` passes, a missing typed payload silently falls back to `ReturnValue.Int32Value`, which for… |
|
||||
| Client.Dotnet-006 | Low | Code organization & conventions | `clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs:50`, `clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs:10-14` | `MxGatewayClientOptions.MaxGrpcMessageBytes` and the two `const`s in `MxGatewayClientContractInfo` are public members with no XML doc comments, inconsistent with every other public member in the assembly and with the repo's documented C# s… |
|
||||
| Client.Dotnet-007 | Low | Documentation & comments | `clients/dotnet/MxGateway.Client/MxGatewayClient.cs:185-192` | The `AcknowledgeAlarmAsync` XML comment states the gateway authenticates against an `invoke:alarm-ack` scope, but `CLAUDE.md` documents the scope set without any `invoke:alarm-ack` sub-scope. The comment may describe an intended finer-grai… |
|
||||
| Client.Dotnet-008 | Low | Correctness & logic bugs | `clients/dotnet/MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs:9-17` | The CLI redactor only removes the API key string when it was supplied via `--api-key`; `RunCoreAsync` passes `arguments.GetOptional("api-key")` to `Redact`. When the key comes from an environment variable (`--api-key-env`, the documented d… |
|
||||
| Client.Go-004 | Low | mxaccessgw conventions | `clients/go/mxgateway/alarms_test.go:153-154`, `clients/go/mxgateway/galaxy_test.go:58-59` | `gofmt -l` flags `alarms_test.go` and `galaxy_test.go` for misaligned struct-literal field padding. The Go client README lists `gofmt` as part of the workflow and the repo enforces style; unformatted committed code breaks `gofmt`-gated che… |
|
||||
| Client.Go-005 | Low | Design-document adherence | `clients/go/mxgateway/client.go:64,68`, `clients/go/mxgateway/galaxy.go:83,87` | The client uses `grpc.DialContext` with `grpc.WithBlock()`. In current grpc-go both are deprecated in favour of `grpc.NewClient` (lazy connection). `WithBlock` also changes failure semantics: a transient gateway-unavailable at dial time be… |
|
||||
| Client.Go-006 | Low | Error handling & resilience | `clients/go/mxgateway/errors.go:9-130` | `docs/ClientLibrariesDesign.md` recommends a high-level error taxonomy (`TransportError`, `AuthenticationError`, `TimeoutError`, etc.). The Go client collapses all transport/gRPC failures into a single `GatewayError` with no way to classif… |
|
||||
| Client.Go-007 | Low | Correctness & logic bugs | `clients/go/mxgateway/session.go:526-532` | `newCorrelationID` returns an empty string when `crypto/rand.Read` fails, silently producing an `MxCommandRequest` with no correlation id. `rand.Read` failure is rare, but the failure mode (untraceable command, no error surfaced) is worse… |
|
||||
| Client.Go-008 | Low | Testing coverage | `clients/go/mxgateway/` (test files) | Several critical paths are untested: TLS credential resolution in `resolveTransportCredentials` (only the `Plaintext` path is exercised); the `callContext` deadline-shortening logic (`client.go:198-204`) including the negative-timeout disa… |
|
||||
| Client.Go-009 | Low | Code organization & conventions | `clients/go/mxgateway/galaxy.go:60-93,241-256`, `clients/go/mxgateway/client.go:41-74,190-205` | `DialGalaxy`/`Dial` and `GalaxyClient.callContext`/`Client.callContext` are near-identical duplicates (dial-context setup, credential resolution, dial-option assembly, deadline arithmetic). A fix to one (e.g. the Client.Go-005 dial migrati… |
|
||||
| Client.Go-010 | Low | Documentation & comments | `clients/go/mxgateway/client.go:39-40` | The `Dial` doc comment states it configures "blocking dial cancellation from ctx." This describes the deprecated `WithBlock` behaviour; once Client.Go-005 is addressed the comment is misleading about how connection establishment and cancel… |
|
||||
| Client.Java-006 | Low | Performance & resource management | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:323-328`, `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClient.java:279-284` | `close()` (the `AutoCloseable` method invoked by try-with-resources) calls only `ownedChannel.shutdown()` and returns immediately without awaiting termination. In-flight calls and Netty event-loop threads may still be running when the call… |
|
||||
| Client.Java-007 | Low | Testing coverage | `clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/` | The alarm surface — `acknowledgeAlarm`/`acknowledgeAlarmAsync`/`queryActiveAlarms` and `MxGatewayActiveAlarmsSubscription` — has zero test coverage. TLS channel construction, the async `streamEventsAsync` path, `MxGatewayEventSubscription`… |
|
||||
| Client.Java-008 | Low | Error handling & resilience | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:298-304` | `acknowledgeAlarmAsync` and `openSessionAsync` apply `ensureProtocolSuccess` inside `thenApply`. If that validator throws a non-`MxGatewayException` `RuntimeException` it is wrapped by `CompletionException` with no `fromGrpc` normalisation… |
|
||||
| Client.Java-009 | Low | Code organization & conventions | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClient.java:310-391`, `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:346-413` | `createChannel`, `withDeadline`, `withStreamDeadline`, and `toCompletable` are duplicated nearly verbatim across `MxGatewayClient` and `GalaxyRepositoryClient` (~80 lines). A fix to one will not propagate to the other. |
|
||||
| Client.Java-010 | Low | Documentation & comments | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:269-272`, `clients/java/README.md:76` | The `acknowledgeAlarm` Javadoc states the gateway authenticates against an `invoke:alarm-ack` scope, and the README states the Galaxy Repository requires a `metadata:read` scope. CLAUDE.md's documented scope set names neither — the Javadoc… |
|
||||
| Client.Java-011 | Low | Performance & resource management | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxEventStream.java:37-63` | The event stream relies on default gRPC auto-inbound flow control: the async stub auto-requests messages, so the server can push faster than the 16-element bounded queue drains. A momentarily slow consumer triggers queue overflow and an im… |
|
||||
| Client.Java-012 | Low | Correctness & logic bugs | `clients/java/mxgateway-cli/src/main/java/com/dohertylan/mxgateway/cli/MxGatewayCli.java:667-674` | `CommonOptions.resolved()` mutates `this` (`resolvedApiKey`, `resolvedTimeout`) and returns `this`, but `toClientOptions()` and `redactedJsonMap()` read those mutated fields. If `redactedJsonMap()` is ever called before `resolved()`, it si… |
|
||||
| Client.Python-001 | Low | Documentation & comments | `clients/python/pyproject.toml:8,25`, `clients/python/src/mxgateway_cli/commands.py:25` | The package `description` in `pyproject.toml` still says "Async Python client *scaffold*" even though the client is fully implemented. Stale "scaffold" wording misrepresents maturity to anyone reading PyPI metadata. (The `mxgw-py` console-… |
|
||||
| Client.Python-002 | Low | Code organization & conventions | `clients/python/src/mxgateway/__init__.py:27` | `MxGatewayCommandError` is imported into `__init__.py` and is a documented public exception, but it is missing from `__all__`. It is the parent of `MxAccessError` and a meaningful catch target, so omitting it from the public surface is inc… |
|
||||
| Client.Python-004 | Low | Correctness & logic bugs | `clients/python/src/mxgateway_cli/commands.py:386,402-404` | In `_smoke`, the local variable `closed` is set to `False` and never reassigned; the `finally` block's `if not closed:` is therefore always true. This is dead/misleading code suggesting a removed early-close path. |
|
||||
| Client.Python-006 | Low | Concurrency & thread safety | `clients/python/src/mxgateway/client.py:74-82`, `clients/python/src/mxgateway/galaxy.py:85-93`, `clients/python/src/mxgateway/session.py:38-55` | `close()` on the clients and `Session.close()` use a plain `self._closed` check-then-set with an `await` between, with no lock. If two coroutines call `close()` concurrently both can pass the guard before either sets it, causing a double `… |
|
||||
| Client.Python-007 | Low | Error handling & resilience | `clients/python/src/mxgateway/client.py:204-213` | `_canceling_iterator` (gateway event stream) does not catch `asyncio.CancelledError` to invoke `call.cancel()` explicitly — it relies on the `finally` block. `galaxy.py:_canceling_iterator` *does* explicitly catch `CancelledError`, cancel,… |
|
||||
| Client.Python-008 | Low | Correctness & logic bugs | `clients/python/src/mxgateway/values.py:62-67,83-88` | `to_mx_value` maps any Python `float` to `VT_R8`/`MX_DATA_TYPE_DOUBLE` with no handling for `nan`/`inf`, which are serialised and forwarded to MXAccess which may reject or mis-handle them. `bytes` is mapped to `VT_RECORD`/`MX_DATA_TYPE_UNK… |
|
||||
| Client.Python-010 | Low | Code organization & conventions | `clients/python/src/mxgateway/session.py:404`, `clients/python/src/mxgateway_cli/commands.py:422-425` | `session.py` ends with a module-level late import `from .client import GatewayClient # noqa: E402` purely to satisfy a string type hint, and `commands.py:_session` does a function-local import. Both work around a circular dependency that `… |
|
||||
| Client.Python-011 | Low | Error handling & resilience | `clients/python/src/mxgateway/errors.py:122-148` | `ensure_mxaccess_success` raises `MxAccessError` if any `mx_status.success == 0`. This treats `success == 0` as the failure sentinel, but `0` is also the proto3 scalar default for an unset `MxStatusProxy`. If the gateway ever returns a rep… |
|
||||
| Client.Python-012 | Low | mxaccessgw conventions | `clients/python/src/mxgateway/client.py:84-108`, `clients/python/src/mxgateway/session.py:57-77` | `Session.invoke_raw` does not run `ensure_mxaccess_success` while `Session.invoke` does, so a caller using `invoke_raw` for parity tests gets a reply where an MXAccess HRESULT failure is silently embedded with no exception. This is by desi… |
|
||||
| Contracts-001 | Low | Design-document adherence | `docs/Grpc.md:13` (and `:3`, `:32`, `:39`) | `mxaccess_gateway.proto` now declares six RPCs on `MxAccessGateway` (`OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, `QueryActiveAlarms`). `docs/Grpc.md` still describes "the four `MxAccessGateway` RPCs" in its… |
|
||||
| Contracts-003 | Low | Code organization & conventions | `src/MxGateway.Contracts/MxGateway.Contracts.csproj:10` | The `<Protobuf>` item for `mxaccess_worker.proto` omits `ProtoRoot="Protos"`, while the items for `mxaccess_gateway.proto` (line 9) and `galaxy_repository.proto` (line 11) both set it. `mxaccess_worker.proto` does `import "mxaccess_gateway… |
|
||||
| Contracts-004 | Low | Documentation & comments | `src/MxGateway.Contracts/GatewayContractInfo.cs:3-6` | The XML summary says the class exposes version metadata "before generated protobuf contracts are introduced." Generated protobuf contracts have long been introduced and are consumed across the solution. The comment is stale; the class now… |
|
||||
| Contracts-005 | Low | mxaccessgw conventions | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto`, `src/MxGateway.Contracts/Protos/mxaccess_worker.proto` | The ProtobufStyleGuide mandates reserving removed field numbers / enum values. Evolution to date has been purely additive, so this is not a current violation — but none of the `.proto` files contain any `reserved` declarations, leaving no… |
|
||||
| Contracts-006 | Low | Correctness & logic bugs | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto:647` | `MxStatusProxy.success` is declared `int32 success = 1` with no comment. The name reads like a boolean flag but the type is a 32-bit integer (mirroring MXAccess `MXSTATUS_PROXY`, which stores a numeric success/HResult-like value). Without… |
|
||||
| Contracts-007 | Low | Testing coverage | `src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs` | `ProtobufContractRoundTripTests` covers gateway command/reply/event, alarm transition, alarm ack request/reply, active-alarm snapshot, and the worker envelope. It has no coverage for: (a) any `galaxy_repository.proto` message (`DiscoverHie… |
|
||||
| Contracts-008 | Low | Design-document adherence | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto:451-459`, `:627-636` | The worker-side `AcknowledgeAlarmReplyPayload` carries the alarm-ack outcome as `int32 native_status`, while the public `AcknowledgeAlarmReply` carries it as `MxStatusProxy status` plus `optional int32 hresult`. The comment explains the wo… |
|
||||
| IntegrationTests-007 | Low | Concurrency & thread safety | `src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs:20`, `src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs:5`, `src/MxGateway.IntegrationTests/DashboardLdapLiveTests.cs:9` | The live test classes contend for genuinely shared singletons — one MXAccess COM provider, one ZB SQL database, one GLAuth instance with a 3-fail/10-minute per-IP lockout. No `[Collection]` annotation or `DisableTestParallelization` is dec… |
|
||||
| IntegrationTests-008 | Low | Code organization & conventions | `src/MxGateway.IntegrationTests/LiveLdapFactAttribute.cs`, `src/MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs`, `src/MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs` | Three near-identical fact attributes each re-implement the same "compare env var to `1` with `StringComparison.Ordinal`, set `Skip` otherwise" pattern. `LiveMxAccessFactAttribute` delegates to `IntegrationTestEnvironment` while the other t… |
|
||||
| IntegrationTests-009 | Low | Documentation & comments | `src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs:372-375` | `TestServerCallContext` is XML-documented as a "Mock server call context," but it is a hand-written stub/fake with no mocking framework and no verification behavior. Per the style guides (accurate naming; explain why not what), calling it… |
|
||||
| IntegrationTests-010 | Low | Correctness & logic bugs | `src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs:366-369` | `WaitForFirstMessageAsync` accepts only a `timeout` and never observes a `CancellationToken`. There is no per-test cancellation propagation, so if the gateway/worker hangs without writing an event the test relies solely on the 15s `WaitAsy… |
|
||||
| Server-007 | Low | Performance & resource management | `src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs:55-70` | `Project` always iterates the full `entry.Index.ObjectViews` collection and re-applies all filters to skip `offset` matched items before collecting a page. Paging through a large Galaxy hierarchy is therefore O(total) per page and O(total²… |
|
||||
| Server-008 | Low | Performance & resource management | `src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs:111-134,160-189` | `WatchDeployEvents` calls `ResolveBrowseSubtrees()` on every streamed event, and `MapDeployEvent` re-runs `GalaxyHierarchyProjector.Project` over the entire cached hierarchy (and `Sum`s attribute counts) for every event of every constraine… |
|
||||
| Server-009 | Low | Error handling & resilience | `src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs:15-32` | Each auth-store operation opens a fresh `SqliteConnection` with no busy timeout, no WAL journal mode, and default journaling. `MarkKeyUsedAsync` runs on every authenticated request and `SqliteApiKeyAuditStore` appends on every denial; unde… |
|
||||
| Server-010 | Low | Security | `src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs:91-114`, `src/MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor:168-172` | `RotateAsync` sets `revoked_utc = NULL`, so rotating a previously revoked key silently reactivates it. This is documented intentional behavior in `docs/Authentication.md:167`, but the dashboard renders the "Rotate" button unconditionally —… |
|
||||
| Server-011 | Low | Code organization & conventions | `src/MxGateway.Server/Sessions/WorkerAlarmRpcDispatcher.cs:1-46` | `WorkerAlarmRpcDispatcher` deviates from the module's conventions: it fully-qualifies `System.Guid`, `System.ArgumentNullException`, and `System.Threading` types inline instead of relying on `using` directives, and uses an explicit constru… |
|
||||
| Server-012 | Low | Documentation & comments | `CLAUDE.md` (Authentication section and `apikey create` example) | CLAUDE.md describes scopes as `session`, `invoke`, `event`, `metadata`, `admin` and shows `apikey create --scopes session,invoke,event,metadata,admin`. The actual canonical scope strings (used by `GatewayScopes`, `GatewayGrpcScopeResolver`… |
|
||||
| Server-013 | Low | Testing coverage | `src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs`, `src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs` | `DashboardAuthorizationHandler` is unit-tested in isolation, but no test exercises the dashboard routes end-to-end to confirm the policy is actually enforced — which is why Server-001 (policy registered but never wired) went uncaught. Ther… |
|
||||
| Server-014 | Low | Documentation & comments | `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:162-171,191-198,206-214,229-237` | The XML `<remarks>` and inline comments on `AcknowledgeAlarm` and `QueryActiveAlarms` describe the alarm path as not yet wired and say `NotWiredAlarmRpcDispatcher` is the default ("Clients calling this method today receive an OK reply with… |
|
||||
| Tests-007 | Low | Code organization & conventions | `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:682`, `src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs:324`, `src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs:460`, `src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs:233` | A near-identical `TestServerCallContext` implementation is copy-pasted into at least four test files (and `AllowAllConstraintEnforcer` / `TestServerStreamWriter` / `RecordingStreamWriter` into several). Duplication risks the copies driftin… |
|
||||
| Tests-008 | Low | mxaccessgw conventions | `src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs:1-9`, `src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs:1-3`, `src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs:1` | The alarm test files diverge from the project's C# style and the rest of the suite: snake_case test method names instead of the PascalCase `Method_Condition_Result` pattern; redundant explicit `using System;`/`System.Threading;` imports de… |
|
||||
| Tests-009 | Low | Documentation & comments | `src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:36-37,99,365` | Several XML `<summary>` comments are copy-paste mismatches: the comment above `OpenSessionAsync_SetsInitialDefaultLease` describes correlation-ID generation; the comment above `GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommand…` desc… |
|
||||
| Tests-010 | Low | Security | `src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs:26-36` | The anonymous-localhost bypass is tested only for the success case (`allowAnonymousLocalhost: true` + loopback succeeds) and the remote-unauthenticated denial. There is no test for the security-critical negatives: anonymous + loopback when… |
|
||||
| Tests-011 | Low | Correctness & logic bugs | `src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs:233-301` | `GatewayEndToEndFakeWorkerSmokeTests` correctly stores and awaits `launcher.WorkerTask`, but `SessionWorkerClientFactoryFakeWorkerTests` uses `_ = RunWorkerAsync(...)` with no stored task (lines 152, 184, 220). An unhandled exception in th… |
|
||||
| Tests-012 | Low | Concurrency & thread safety | `src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs:62`, `src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs:472` | Pipe names are uniquified per test with a GUID (good), but xUnit runs test classes in parallel by default and there is no `xunit.runner.json` or collection configuration. Tests that build a full `WebApplication` bind ephemeral ports (`--ur… |
|
||||
| Worker-009 | Low | Performance & resource management | `src/MxGateway.Worker/Ipc/WorkerFrameReader.cs:31,49`, `src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs:57-58` | Every frame read allocates a fresh 4-byte length buffer and a payload `byte[]`; every write allocates `ToByteArray()` plus a 4-byte prefix. On the hot event-drain path (batches of up to 128 `WorkerEvent` frames every 25 ms) this produces s… |
|
||||
| Worker-010 | Low | Correctness & logic bugs | `src/MxGateway.Worker/Conversion/VariantConverter.cs:204-226` | `ConvertInt64Scalar` is reached for `TypeCode.UInt32` and `TypeCode.Int64`. For a `uint` with `expectedDataType == MxDataType.Time`, the value is treated as a Windows `FILETIME` via `DateTime.FromFileTimeUtc(longValue)`; a 32-bit FILETIME… |
|
||||
| Worker-011 | Low | Correctness & logic bugs | `src/MxGateway.Worker/Ipc/WorkerPipeClient.cs:169-171` | `retryAttempts` is computed as `(connectTimeout / min(connectTimeout, attemptTimeout)) - 1`. With defaults (30000 / 2000) this yields 14 retries, but each retry also incurs Polly exponential backoff. The overall `connectDeadline` (`CancelA… |
|
||||
| Worker-012 | Low | Documentation & comments | `src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs:44-55`, `src/MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs:38-43`, `src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs:106-112` | Multiple comments describe the alarm path as not-yet-wired future work ("PR A.2 — COM-side subscription scaffold … the worker advertises no alarm subscription", "the worker bootstrap will gain a thin 'run-on-STA' wrapper as part of A.3").… |
|
||||
| Worker-013 | Low | Testing coverage | `src/MxGateway.Worker/Sta/StaMessagePump.cs` | `StaMessagePump` — the heart of COM event delivery (`MsgWaitForMultipleObjectsEx` + `PeekMessage`/`DispatchMessage`) — has no direct unit tests. `StaRuntimeTests` exercises it indirectly for command wake-up but never verifies that a posted… |
|
||||
| Worker-014 | Low | Code organization & conventions | `src/MxGateway.Worker/MxAccess/AlarmCommandHandler.cs:33`, `:202` | The file declares two public types — the `AlarmCommandHandler` class and the `IAlarmCommandHandler` interface. The C# style guide and the rest of the module follow one-public-type-per-file (e.g. interfaces in their own `I*.cs` files like `… |
|
||||
| Worker-015 | Low | Correctness & logic bugs | `src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs:115-145` | On overflow, `Enqueue` records the overflow fault and throws `MxAccessEventQueueOverflowException`; `MxAccessBaseEventSink.EnqueueEvent` catches it and calls `RecordFault` again. `RecordFault` is a no-op when a fault already exists, so the… |
|
||||
| Worker.Tests-008 | Low | Documentation & comments | `src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs:175-182` | `Redactor_WithCredentialBearingValueFields_RedactsBeforeLogging` lives in `VariantConverterTests` but asserts on `WorkerLogRedactor.RedactValue`, which has nothing to do with `VariantConverter`. It is also a near-duplicate of coverage in `… |
|
||||
| Worker.Tests-009 | Low | Code organization & conventions | `src/MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs`, `AlarmDispatcherTests.cs`, `AlarmCommandExecutorTests.cs`, `AlarmRecordTransitionMapperTests.cs`, `WnWrapAlarmConsumerXmlTests.cs` | The alarm-related test files use `snake_case` method names while the rest of the project uses the `Method_State_Result` PascalCase convention. `docs/style-guides/CSharpStyleGuide.md` and the surrounding code establish PascalCase as the pro… |
|
||||
| Worker.Tests-010 | Low | Correctness & logic bugs | `src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs:230-258` | `StartAsync_WithoutAlarmCommandHandlerFactory_SubscribeAlarmsReturnsInvalidRequest` asserts `Assert.Contains("alarm", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase)`. The XML doc claims it verifies the diagnostic says "alarm… |
|
||||
| Worker.Tests-011 | Low | Documentation & comments | `src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs:92-112` | `DispatchAsync_WhenCanceledAfterExecutionStarts_StillReturnsLateReply` is named and documented as if it proves cancellation arrived after execution began. The test does `Started.Wait(...)` then `cancellation.Cancel()`, which proves executi… |
|
||||
| Worker.Tests-012 | Low | Testing coverage | `src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs` | `docs/WorkerFrameProtocol.md` states the reader "rejects zero-length payloads and payloads larger than the configured maximum (default 16 MiB) before allocating the payload buffer." `WorkerFrameProtocolTests` covers malformed-length, wrong… |
|
||||
| Worker.Tests-013 | Low | Concurrency & thread safety | `src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs:539-546` | `ThrowIfCompletedAsync` does an unconditional `await Task.Delay(TimeSpan.FromMilliseconds(100))` then checks `task.IsCompleted`. This adds a fixed 100 ms to the test and only catches a `RunAsync` that fails within that arbitrary window; a… |
|
||||
| Worker.Tests-014 | Low | Code organization & conventions | `src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs:194`, `WorkerPipeSessionTests.cs:622`, `Sta/StaCommandDispatcherTests.cs:348`, `MxAccess/MxAccessStaSessionTests.cs:334`, `MxAccess/MxAccessCommandExecutorTests.cs:1124` | `FakeRuntimeSession`, `NoopComApartmentInitializer`, `NoopEventSink`/`NullEventSink`, and the `CreateFrame`/`WriteUInt32LittleEndian` helpers are re-implemented independently in multiple test files. The two `FakeRuntimeSession` implementat… |
|
||||
| Worker.Tests-015 | Low | Testing coverage | `src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs` | `MxAccessEventQueueTests` covers monotonic sequencing, drain, capacity overflow, and first-fault-wins, but does not cover `Drain` with `maxEvents: 0` (drain-all) — a branch `FakeRuntimeSession.DrainEvents` even special-cases — nor draining… |
|
||||
_No pending findings._
|
||||
|
||||
## Closed findings
|
||||
|
||||
@@ -157,9 +88,77 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Worker.Tests-005 | Medium | Resolved | Performance & resource management | `src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs:20-31,103-105`, `src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs:28-31` |
|
||||
| Worker.Tests-006 | Medium | Resolved | Performance & resource management | `src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs:282,305,315,323` |
|
||||
| Worker.Tests-007 | Medium | Resolved | Design-document adherence | `docs/WorkerFrameProtocol.md:38-49` |
|
||||
| Client.Dotnet-004 | Low | Resolved | Error handling & resilience | `clients/dotnet/MxGateway.Client/MxGatewayClient.cs:283-294`, `clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs:392-403` |
|
||||
| Client.Dotnet-005 | Low | Resolved | Correctness & logic bugs | `clients/dotnet/MxGateway.Client/MxGatewaySession.cs:82,124,175` |
|
||||
| Client.Dotnet-006 | Low | Resolved | Code organization & conventions | `clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs:50`, `clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs:10-14` |
|
||||
| Client.Dotnet-007 | Low | Resolved | Documentation & comments | `clients/dotnet/MxGateway.Client/MxGatewayClient.cs:185-192` |
|
||||
| Client.Dotnet-008 | Low | Resolved | Correctness & logic bugs | `clients/dotnet/MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs:9-17` |
|
||||
| Client.Go-004 | Low | Resolved | mxaccessgw conventions | `clients/go/mxgateway/alarms_test.go:153-154`, `clients/go/mxgateway/galaxy_test.go:58-59` |
|
||||
| Client.Go-005 | Low | Resolved | Design-document adherence | `clients/go/mxgateway/client.go:64,68`, `clients/go/mxgateway/galaxy.go:83,87` |
|
||||
| Client.Go-006 | Low | Resolved | Error handling & resilience | `clients/go/mxgateway/errors.go:9-130` |
|
||||
| Client.Go-007 | Low | Resolved | Correctness & logic bugs | `clients/go/mxgateway/session.go:526-532` |
|
||||
| Client.Go-008 | Low | Resolved | Testing coverage | `clients/go/mxgateway/` (test files) |
|
||||
| Client.Go-009 | Low | Resolved | Code organization & conventions | `clients/go/mxgateway/galaxy.go:60-93,241-256`, `clients/go/mxgateway/client.go:41-74,190-205` |
|
||||
| Client.Go-010 | Low | Resolved | Documentation & comments | `clients/go/mxgateway/client.go:39-40` |
|
||||
| Client.Java-006 | Low | Resolved | Performance & resource management | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:323-328`, `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClient.java:279-284` |
|
||||
| Client.Java-007 | Low | Resolved | Testing coverage | `clients/java/mxgateway-client/src/test/java/com/dohertylan/mxgateway/client/` |
|
||||
| Client.Java-008 | Low | Resolved | Error handling & resilience | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:298-304` |
|
||||
| Client.Java-009 | Low | Resolved | Code organization & conventions | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/GalaxyRepositoryClient.java:310-391`, `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:346-413` |
|
||||
| Client.Java-010 | Low | Resolved | Documentation & comments | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxGatewayClient.java:269-272`, `clients/java/README.md:76` |
|
||||
| Client.Java-011 | Low | Resolved | Performance & resource management | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/MxEventStream.java:37-63` |
|
||||
| Client.Java-012 | Low | Resolved | Correctness & logic bugs | `clients/java/mxgateway-cli/src/main/java/com/dohertylan/mxgateway/cli/MxGatewayCli.java:667-674` |
|
||||
| Client.Python-001 | Low | Resolved | Documentation & comments | `clients/python/pyproject.toml:8,25`, `clients/python/src/mxgateway_cli/commands.py:25` |
|
||||
| Client.Python-002 | Low | Resolved | Code organization & conventions | `clients/python/src/mxgateway/__init__.py:27` |
|
||||
| Client.Python-004 | Low | Resolved | Correctness & logic bugs | `clients/python/src/mxgateway_cli/commands.py:386,402-404` |
|
||||
| Client.Python-006 | Low | Resolved | Concurrency & thread safety | `clients/python/src/mxgateway/client.py:74-82`, `clients/python/src/mxgateway/galaxy.py:85-93`, `clients/python/src/mxgateway/session.py:38-55` |
|
||||
| Client.Python-007 | Low | Resolved | Error handling & resilience | `clients/python/src/mxgateway/client.py:204-213` |
|
||||
| Client.Python-008 | Low | Resolved | Correctness & logic bugs | `clients/python/src/mxgateway/values.py:62-67,83-88` |
|
||||
| Client.Python-010 | Low | Resolved | Code organization & conventions | `clients/python/src/mxgateway/session.py:404`, `clients/python/src/mxgateway_cli/commands.py:422-425` |
|
||||
| Client.Python-011 | Low | Resolved | Error handling & resilience | `clients/python/src/mxgateway/errors.py:122-148` |
|
||||
| Client.Python-012 | Low | Won't Fix | mxaccessgw conventions | `clients/python/src/mxgateway/client.py:84-108`, `clients/python/src/mxgateway/session.py:57-77` |
|
||||
| Client.Rust-004 | Low | Resolved | Documentation & comments | `clients/rust/src/version.rs:7` |
|
||||
| Client.Rust-007 | Low | Resolved | Design-document adherence | `clients/rust/RustClientDesign.md:14-55` |
|
||||
| Client.Rust-008 | Low | Resolved | Performance & resource management | `clients/rust/src/value.rs:161-261` |
|
||||
| Client.Rust-009 | Low | Resolved | Testing coverage | `clients/rust/tests/client_behavior.rs`, `clients/rust/src/galaxy.rs` |
|
||||
| Client.Rust-010 | Low | Resolved | Error handling & resilience | `clients/rust/src/client.rs:255-268`, `clients/rust/src/galaxy.rs:204-216` |
|
||||
| Client.Rust-011 | Low | Resolved | mxaccessgw conventions | `clients/rust/src/session.rs:469` |
|
||||
| Contracts-001 | Low | Resolved | Design-document adherence | `docs/Grpc.md:13` (and `:3`, `:32`, `:39`) |
|
||||
| Contracts-003 | Low | Won't Fix | Code organization & conventions | `src/MxGateway.Contracts/MxGateway.Contracts.csproj:10` |
|
||||
| Contracts-004 | Low | Resolved | Documentation & comments | `src/MxGateway.Contracts/GatewayContractInfo.cs:3-6` |
|
||||
| Contracts-005 | Low | Resolved | mxaccessgw conventions | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto`, `src/MxGateway.Contracts/Protos/mxaccess_worker.proto` |
|
||||
| Contracts-006 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto:647` |
|
||||
| Contracts-007 | Low | Resolved | Testing coverage | `src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs` |
|
||||
| Contracts-008 | Low | Resolved | Design-document adherence | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto:451-459`, `:627-636` |
|
||||
| IntegrationTests-007 | Low | Resolved | Concurrency & thread safety | `src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs:20`, `src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs:5`, `src/MxGateway.IntegrationTests/DashboardLdapLiveTests.cs:9` |
|
||||
| IntegrationTests-008 | Low | Resolved | Code organization & conventions | `src/MxGateway.IntegrationTests/LiveLdapFactAttribute.cs`, `src/MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs`, `src/MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs` |
|
||||
| IntegrationTests-009 | Low | Resolved | Documentation & comments | `src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs:372-375` |
|
||||
| IntegrationTests-010 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs:366-369` |
|
||||
| Server-007 | Low | Resolved | Performance & resource management | `src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs:55-70` |
|
||||
| Server-008 | Low | Resolved | Performance & resource management | `src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs:111-134,160-189` |
|
||||
| Server-009 | Low | Resolved | Error handling & resilience | `src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs:15-32` |
|
||||
| Server-010 | Low | Resolved | Security | `src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs:91-114`, `src/MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor:168-172` |
|
||||
| Server-011 | Low | Resolved | Code organization & conventions | `src/MxGateway.Server/Sessions/WorkerAlarmRpcDispatcher.cs:1-46` |
|
||||
| Server-012 | Low | Resolved | Documentation & comments | `CLAUDE.md` (Authentication section and `apikey create` example) |
|
||||
| Server-013 | Low | Resolved | Testing coverage | `src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs`, `src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs` |
|
||||
| Server-014 | Low | Resolved | Documentation & comments | `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:162-171,191-198,206-214,229-237` |
|
||||
| Tests-007 | Low | Resolved | Code organization & conventions | `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:682`, `src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs:324`, `src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs:460`, `src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs:233` |
|
||||
| Tests-008 | Low | Resolved | mxaccessgw conventions | `src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs:1-9`, `src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs:1-3`, `src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs:1` |
|
||||
| Tests-009 | Low | Resolved | Documentation & comments | `src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:36-37,99,365` |
|
||||
| Tests-010 | Low | Resolved | Security | `src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs:26-36` |
|
||||
| Tests-011 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs:233-301` |
|
||||
| Tests-012 | Low | Resolved | Concurrency & thread safety | `src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs:62`, `src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs:472` |
|
||||
| Worker-009 | Low | Resolved | Performance & resource management | `src/MxGateway.Worker/Ipc/WorkerFrameReader.cs:31,49`, `src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs:57-58` |
|
||||
| Worker-010 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Conversion/VariantConverter.cs:204-226` |
|
||||
| Worker-011 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Ipc/WorkerPipeClient.cs:169-171` |
|
||||
| Worker-012 | Low | Resolved | Documentation & comments | `src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs:44-55`, `src/MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs:38-43`, `src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs:106-112` |
|
||||
| Worker-013 | Low | Resolved | Testing coverage | `src/MxGateway.Worker/Sta/StaMessagePump.cs` |
|
||||
| Worker-014 | Low | Resolved | Code organization & conventions | `src/MxGateway.Worker/MxAccess/AlarmCommandHandler.cs:33`, `:202` |
|
||||
| Worker-015 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs:115-145` |
|
||||
| Worker.Tests-008 | Low | Resolved | Documentation & comments | `src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs:175-182` |
|
||||
| Worker.Tests-009 | Low | Resolved | Code organization & conventions | `src/MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs`, `AlarmDispatcherTests.cs`, `AlarmCommandExecutorTests.cs`, `AlarmRecordTransitionMapperTests.cs`, `WnWrapAlarmConsumerXmlTests.cs` |
|
||||
| Worker.Tests-010 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs:230-258` |
|
||||
| Worker.Tests-011 | Low | Resolved | Documentation & comments | `src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs:92-112` |
|
||||
| Worker.Tests-012 | Low | Resolved | Testing coverage | `src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs` |
|
||||
| Worker.Tests-013 | Low | Resolved | Concurrency & thread safety | `src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs:539-546` |
|
||||
| Worker.Tests-014 | Low | Resolved | Code organization & conventions | `src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs:194`, `WorkerPipeSessionTests.cs:622`, `Sta/StaCommandDispatcherTests.cs:348`, `MxAccess/MxAccessStaSessionTests.cs:334`, `MxAccess/MxAccessCommandExecutorTests.cs:1124` |
|
||||
| Worker.Tests-015 | Low | Resolved | Testing coverage | `src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs` |
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-18 |
|
||||
| Commit reviewed | `6c64030` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 8 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -123,13 +123,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Performance & resource management |
|
||||
| Location | `src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs:55-70` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `Project` always iterates the full `entry.Index.ObjectViews` collection and re-applies all filters to skip `offset` matched items before collecting a page. Paging through a large Galaxy hierarchy is therefore O(total) per page and O(total²/pageSize) end-to-end. The cache is in-memory so impact is bounded, but for large galaxies repeated `DiscoverHierarchy` pagination wastes CPU.
|
||||
|
||||
**Recommendation:** Precompute and cache the filtered, ordered view list per `(filterSignature, sequence)` so subsequent pages are an O(pageSize) slice; the existing filter signature already keys page tokens.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18. Confirmed against source: `Project` re-scanned and re-filtered the whole `ObjectViews` list on every page. Added a `ConditionalWeakTable<GalaxyHierarchyCacheEntry, ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>>` memo in `GalaxyHierarchyProjector`: the first projection of a given filter signature builds the filtered, ordered view list; subsequent pages take an O(pageSize) slice via index arithmetic. The memo is keyed on the immutable cache-entry instance, so when the cache publishes a new entry the stale memo becomes unreachable and is reclaimed with it — no explicit invalidation. `ResolveRoot` still runs before the memo lookup so a missing root surfaces `NotFound` consistently. Regression tests: `GalaxyHierarchyProjectorTests` (`Project_PagedAcrossEntireHierarchy_ReturnsEveryObjectExactlyOnce`, `Project_DistinctFiltersOnSameEntry_DoNotShareMemoizedViewList`, `Project_SameFilterRepeated_ReturnsIdenticalTotals`, `Project_DistinctCacheEntries_ProjectAgainstTheirOwnData`); existing `GalaxyRepositoryGrpcServiceTests` paging tests continue to pass unchanged.
|
||||
|
||||
### Server-008
|
||||
|
||||
@@ -138,13 +138,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Performance & resource management |
|
||||
| Location | `src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs:111-134,160-189` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `WatchDeployEvents` calls `ResolveBrowseSubtrees()` on every streamed event, and `MapDeployEvent` re-runs `GalaxyHierarchyProjector.Project` over the entire cached hierarchy (and `Sum`s attribute counts) for every event of every constrained subscriber. `GalaxyGlobMatcher.IsMatch` also rebuilds the glob regex on each call. With many constrained subscribers and frequent deploys this is avoidable work.
|
||||
|
||||
**Recommendation:** Hoist `ResolveBrowseSubtrees()` out of the loop; compute scoped object/attribute counts once per deploy sequence and cache by `(sequence, browseSubtrees)`; cache compiled glob `Regex` instances in `GalaxyGlobMatcher`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18. Confirmed against source. Three changes: (1) `WatchDeployEvents` now resolves `ResolveBrowseSubtrees()` once before the streaming loop — the caller's identity and constraints are fixed for the stream lifetime, so per-event resolution was pure waste. (2) `GalaxyGlobMatcher` now caches compiled `Regex` instances in a `ConcurrentDictionary` keyed by glob pattern (with `RegexOptions.Compiled`), so the same handful of globs are translated once instead of on every `IsMatch` call. (3) The per-event `MapDeployEvent` re-projection is no longer a separate hot path: with finding Server-007 resolved, `GalaxyHierarchyProjector.Project` memoizes the filtered view list per `(cache entry, filter signature)`, so the scoped-count projection in `MapDeployEvent` for a constrained subscriber is O(matched-slice) after the first event of a given deploy sequence rather than a full re-scan — this subsumes the recommendation's `(sequence, browseSubtrees)` cache (the memo is keyed on the per-sequence cache-entry instance and the browse-subtree-bearing filter signature). Regression tests: `GalaxyFilterInputSafetyTests.GlobMatcher_RepeatedAndInterleavedPatterns_StayCorrect` (glob cache correctness); existing `WatchDeployEvents` and `GalaxyFilterInputSafetyTests` coverage continues to pass.
|
||||
|
||||
### Server-009
|
||||
|
||||
@@ -153,13 +153,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs:15-32` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Each auth-store operation opens a fresh `SqliteConnection` with no busy timeout, no WAL journal mode, and default journaling. `MarkKeyUsedAsync` runs on every authenticated request and `SqliteApiKeyAuditStore` appends on every denial; under concurrent load these writers can collide and surface `SQLITE_BUSY` as a hard failure on the request path.
|
||||
|
||||
**Recommendation:** Set `Pooling`, a non-zero `DefaultTimeout`/`busy_timeout`, and enable WAL (`PRAGMA journal_mode=WAL`) once at startup so concurrent readers/writers degrade gracefully.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18. Confirmed against source: the connection string set only `DataSource` and `Mode`. `AuthSqliteConnectionFactory.CreateConnection` now also sets `Pooling = true` and a non-zero `DefaultTimeout`. A new `OpenConnectionAsync(CancellationToken)` opens the connection and applies `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout` (5 s); WAL is a persistent database-level setting so re-applying it per connection is a cheap no-op, while `busy_timeout` is per-connection state. All nine auth-store call sites (`SqliteApiKeyAdminStore`, `SqliteApiKeyAuditStore`, `SqliteApiKeyStore`, `SqliteAuthStoreMigrator`) were switched from `CreateConnection()` + `OpenAsync()` to `OpenConnectionAsync()`. `docs/Authentication.md` updated to describe the WAL/busy-timeout behavior. Regression test: `SqliteAuthStoreTests.OpenConnectionAsync_EnablesWalJournalModeAndBusyTimeout`.
|
||||
|
||||
### Server-010
|
||||
|
||||
@@ -168,13 +168,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Security |
|
||||
| Location | `src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs:91-114`, `src/MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor:168-172` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `RotateAsync` sets `revoked_utc = NULL`, so rotating a previously revoked key silently reactivates it. This is documented intentional behavior in `docs/Authentication.md:167`, but the dashboard renders the "Rotate" button unconditionally — including for keys whose status badge says "Revoked" — so an operator can un-revoke a deliberately disabled key without an explicit warning.
|
||||
|
||||
**Recommendation:** Either hide/disable the Rotate action for revoked keys in `ApiKeysPage.razor`, require an explicit confirmation, or have `RotateAsync` preserve `revoked_utc` and add a separate explicit "reactivate" operation.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18. Confirmed against source: `ApiKeysPage.razor` rendered the Rotate button unconditionally while Revoke was already gated on `key.RevokedUtc is null`. Took the lowest-risk recommended option — the dashboard now renders the Rotate (and Revoke) actions only for keys whose status is `Active`; a revoked key shows a "No actions" placeholder, so an operator cannot un-revoke a deliberately disabled key as a side effect of a rotation. `RotateAsync`'s store-level behavior is unchanged (rotation by `key_id` still clears `revoked_utc`, which the CLI relies on); `docs/Authentication.md` updated to document both the store behavior and the dashboard restriction. No automated test added: the change is pure conditional Razor rendering and the test project has no bUnit component-rendering harness; the underlying `DashboardApiKeyManagementService` is already unit-tested.
|
||||
|
||||
### Server-011
|
||||
|
||||
@@ -183,13 +183,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `src/MxGateway.Server/Sessions/WorkerAlarmRpcDispatcher.cs:1-46` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `WorkerAlarmRpcDispatcher` deviates from the module's conventions: it fully-qualifies `System.Guid`, `System.ArgumentNullException`, and `System.Threading` types inline instead of relying on `using` directives, and uses an explicit constructor with `this.`-qualified field assignment while the rest of the module (e.g. `ConstraintEnforcer`, `MxAccessGatewayService`, `GalaxyRepositoryGrpcService`) uses primary constructors. `docs/style-guides/CSharpStyleGuide.md` is authoritative for gateway code.
|
||||
|
||||
**Recommendation:** Add the needed `using` directives, drop the inline fully-qualified names, and convert to a primary constructor for consistency.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18. Confirmed against source. Converted `WorkerAlarmRpcDispatcher` to a primary constructor with the standard `?? throw new ArgumentNullException(...)` field-initializer guard; dropped the inline `System.Guid` / `System.ArgumentNullException` qualifications (using implicit `using System;`); removed redundant `using System.Collections.Generic;` / `System.Threading` / `System.Threading.Tasks;` directives (covered by `ImplicitUsings`); replaced the two `if (... is null) throw new System.ArgumentNullException(...)` checks with `ArgumentNullException.ThrowIfNull`. The stale class-level `<summary>`/`<remarks>` ("Replaces NotWiredAlarmRpcDispatcher once ... wired in", "partially wired", "returns an Unimplemented diagnostic") were corrected to describe the actual GUID-vs-`Provider!Group.Tag` handling — overlapping with Server-014. No behavior change, so no new test; existing `WorkerAlarmRpcDispatcherTests` continue to pass and the project builds warning-free under `TreatWarningsAsErrors`.
|
||||
|
||||
### Server-012
|
||||
|
||||
@@ -198,13 +198,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `CLAUDE.md` (Authentication section and `apikey create` example) |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** CLAUDE.md describes scopes as `session`, `invoke`, `event`, `metadata`, `admin` and shows `apikey create --scopes session,invoke,event,metadata,admin`. The actual canonical scope strings (used by `GatewayScopes`, `GatewayGrpcScopeResolver`, and `docs/Authorization.md`) are `session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`. A key created per the CLAUDE.md example carries scopes the resolver never matches.
|
||||
|
||||
**Recommendation:** Update CLAUDE.md's scope list and the `apikey` example to the canonical `*:*` scope strings, per CLAUDE.md's own rule that docs change with the code.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18. Confirmed against `GatewayScopes` (`session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`). CLAUDE.md's Build/Test/Run `apikey create` example and the Authentication-section scope list were both updated to the canonical `*:*` strings. (Note: since finding Server-004 was resolved, the old example would now be actively rejected at create time rather than silently creating an unusable key, making the doc correction load-bearing.) Pure documentation change; no test.
|
||||
|
||||
### Server-013
|
||||
|
||||
@@ -213,13 +213,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Location | `src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs`, `src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `DashboardAuthorizationHandler` is unit-tested in isolation, but no test exercises the dashboard routes end-to-end to confirm the policy is actually enforced — which is why Server-001 (policy registered but never wired) went uncaught. There are also no tests for `WorkerExecutableValidator` (PE-header architecture parsing), `GalaxyGlobMatcher` (anchoring/escaping/empty-glob fail-open), or `GalaxyHierarchyProjector` pagination/page-token behavior.
|
||||
|
||||
**Recommendation:** Add a `WebApplicationFactory` integration test that requests a dashboard page unauthenticated and asserts the redirect/401, plus unit tests for `WorkerExecutableValidator`, `GalaxyGlobMatcher`, and projector paging.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18. Re-triaged against the current test suite: three of the four named gaps were already closed. (1) The dashboard route-level enforcement test exists — `GatewayApplicationTests.Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization` (and `..._AuthEndpointsAllowAnonymousAccess`), added when Server-001 was fixed. (2) `GalaxyGlobMatcher` anchoring/escaping/empty-glob behavior is covered by `GalaxyFilterInputSafetyTests` (`GlobMatcher_TreatsSqlMetacharactersAsLiterals`, `GlobMatcher_DoesNotTreatLikeWildcardsAsWildcards`, `GlobMatcher_WithPathologicalInput_DoesNotHang`), now extended with `GlobMatcher_RepeatedAndInterleavedPatterns_StayCorrect`. (3) Projector pagination/page-token behavior is covered end-to-end by `GalaxyRepositoryGrpcServiceTests` and now directly by the new `GalaxyHierarchyProjectorTests`. The one genuine remaining gap — `WorkerExecutableValidator` PE-header parsing — was closed with the new `WorkerExecutableValidatorTests` (7 cases: matching/mismatched x86 and x64, missing `MZ` header, file too small, missing `PE` signature), exercising the validator against synthesized minimal PE fixtures.
|
||||
|
||||
### Server-014
|
||||
|
||||
@@ -228,10 +228,10 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:162-171,191-198,206-214,229-237` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The XML `<remarks>` and inline comments on `AcknowledgeAlarm` and `QueryActiveAlarms` describe the alarm path as not yet wired and say `NotWiredAlarmRpcDispatcher` is the default ("Clients calling this method today receive an OK reply with a 'worker alarm path not yet wired' diagnostic", "an empty stream until PR A.2"). In fact `SessionServiceCollectionExtensions.AddGatewaySessions` registers `WorkerAlarmRpcDispatcher` as `IAlarmRpcDispatcher`, so DI always injects the production dispatcher; `NotWiredAlarmRpcDispatcher` is only the null fallback. The comments are stale and misleading.
|
||||
|
||||
**Recommendation:** Update the `AcknowledgeAlarm`/`QueryActiveAlarms` remarks to reflect that `WorkerAlarmRpcDispatcher` is the wired default, and describe its actual GUID-vs-`Provider!Group.Tag` handling.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18. Confirmed against source: `SessionServiceCollectionExtensions` registers `WorkerAlarmRpcDispatcher` as `IAlarmRpcDispatcher`, so the "not yet wired" / "empty stream until PR A.2" / "PR A.6/A.7 follow-up" prose in the `AcknowledgeAlarm` and `QueryActiveAlarms` `<remarks>` and inline comments was stale. Rewrote both `<remarks>` blocks and both inline comments to state that DI binds the production `WorkerAlarmRpcDispatcher`, that it routes over the worker pipe IPC, and that `AcknowledgeAlarm` handles a canonical-GUID reference (→ `AcknowledgeAlarmCommand`) and a `Provider!Group.Tag` reference (→ `AcknowledgeAlarmByNameCommand`), with `NotWiredAlarmRpcDispatcher` being only the null fallback. The matching stale `WorkerAlarmRpcDispatcher` class-level XML doc was corrected as part of Server-011. Pure documentation/comment change; no test.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-18 |
|
||||
| Commit reviewed | `6c64030` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 6 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -127,13 +127,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:682`, `src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs:324`, `src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs:460`, `src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs:233` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** A near-identical `TestServerCallContext` implementation is copy-pasted into at least four test files (and `AllowAllConstraintEnforcer` / `TestServerStreamWriter` / `RecordingStreamWriter` into several). Duplication risks the copies drifting and bloats each file.
|
||||
|
||||
**Recommendation:** Extract a shared `TestServerCallContext`, `RecordingServerStreamWriter<T>`, and `AllowAllConstraintEnforcer` into a common test-support folder/namespace.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: confirmed five duplicated copies (the brief's four plus a fifth in `Galaxy/GalaxyFilterInputSafetyTests.cs`). Added a shared `MxGateway.Tests.TestSupport` namespace under `src/MxGateway.Tests/TestSupport/`: `TestServerCallContext.cs` (single class with an optional `Metadata? requestHeaders` constructor parameter that subsumes both the no-arg and headers-bearing variants), `RecordingServerStreamWriter.cs` (thread-safe writer with `Messages` and `WaitForFirstMessageAsync`, replacing `TestServerStreamWriter`/`RecordingStreamWriter`/`RecordingServerStreamWriter`), and `AllowAllConstraintEnforcer.cs`. Deleted all five `TestServerCallContext` copies, both `AllowAllConstraintEnforcer` copies, and the three stream-writer copies; updated the five test files to `using MxGateway.Tests.TestSupport;` and renamed `.Items` call sites to `.Messages`. Removed the now-unused `Grpc.Core` using from `GatewayEndToEndFakeWorkerSmokeTests.cs`. Build clean (0 warnings) and suite green.
|
||||
|
||||
### Tests-008
|
||||
|
||||
@@ -142,13 +142,15 @@
|
||||
| Severity | Low |
|
||||
| Category | mxaccessgw conventions |
|
||||
| Location | `src/MxGateway.Tests/Gateway/Sessions/WorkerAlarmRpcDispatcherTests.cs:1-9`, `src/MxGateway.Tests/Gateway/Sessions/NotWiredAlarmRpcDispatcherTests.cs:1-3`, `src/MxGateway.Tests/Gateway/Sessions/SessionManagerAlarmAutoSubscribeTests.cs:1` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The alarm test files diverge from the project's C# style and the rest of the suite: snake_case test method names instead of the PascalCase `Method_Condition_Result` pattern; redundant explicit `using System;`/`System.Threading;` imports despite implicit global usings; and explicit-type `new` instead of target-typed `new()` used elsewhere. There is also a typo in fixture data (`"wnwrap subscribe failed"`).
|
||||
|
||||
**Recommendation:** Rename the alarm tests to the house `Method_Condition_Result` convention, drop redundant `System.*` usings, align `new` usage, and fix the `wnwrap` typo.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Re-triage note:** Two of the finding's claims are incorrect. (1) `"wnwrap subscribe failed"` is **not a typo** — `WnWrap` is the real name of the worker's `WnWrapAlarmConsumer` MXAccess component (`src/MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs`); the fixture string deliberately references it, so it was left unchanged. (2) `SessionManagerAlarmAutoSubscribeTests.cs` already uses PascalCase `Method_Condition_Result` names and target-typed `new()`, and its lone `using System.Runtime.CompilerServices;` is **required** for `[EnumeratorCancellation]` (not a global using) — it is not redundant. That file needed no change. The genuine style drift was confined to `WorkerAlarmRpcDispatcherTests.cs` and `NotWiredAlarmRpcDispatcherTests.cs`.
|
||||
|
||||
**Resolution:** Resolved 2026-05-18: renamed all ten `WorkerAlarmRpcDispatcherTests` methods and both `NotWiredAlarmRpcDispatcherTests` methods from snake_case to the house `Method_Condition_Result` PascalCase convention; dropped the redundant `System`/`System.Collections.Generic`/`System.Linq`/`System.Threading`/`System.Threading.Tasks` usings from `WorkerAlarmRpcDispatcherTests.cs` and `System.Threading`/`System.Threading.Tasks` from `NotWiredAlarmRpcDispatcherTests.cs` (all are implicit global usings), keeping the required `System.Runtime.CompilerServices`; converted explicit-type `new SessionRegistry()`/`new WorkerAlarmRpcDispatcher(...)`/`new FakeAlarmWorkerClient`/`new List<...>()`/`new GatewaySession(...)` to target-typed `new()`; and replaced the fully-qualified `System.StringComparison` with `StringComparison`. See the re-triage note for the two claims not actioned. Suite green.
|
||||
|
||||
### Tests-009
|
||||
|
||||
@@ -157,13 +159,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:36-37,99,365` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Several XML `<summary>` comments are copy-paste mismatches: the comment above `OpenSessionAsync_SetsInitialDefaultLease` describes correlation-ID generation; the comment above `GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommand…` describes lease refresh; the comment above `CloseExpiredLeasesAsync_DoesNotCloseActiveEventSubscriber` describes shutdown closing all sessions. Misleading test docs hinder triage.
|
||||
|
||||
**Recommendation:** Correct the `<summary>` text to match each test's actual behavior, or remove the redundant comments since the test names already describe the behavior.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: confirmed three copy-paste `<summary>` mismatches. The mislabelled comments were the summaries of the *following* tests left attached to the wrong method (the test below each then had no summary). Corrected all three: `OpenSessionAsync_SetsInitialDefaultLease` now describes setting the initial lease expiry; the comment above `InvokeAsync_WhenSessionReady_RefreshesLease` (the finding mis-cited the method name as `GatewaySessionSubscribeBulkAsync_…`) now describes lease refresh on invoke; and `CloseExpiredLeasesAsync_DoesNotCloseActiveEventSubscriber` now describes the expired-lease sweep leaving an active-event-subscriber session open. No behavior change.
|
||||
|
||||
### Tests-010
|
||||
|
||||
@@ -172,13 +174,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Security |
|
||||
| Location | `src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs:26-36` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The anonymous-localhost bypass is tested only for the success case (`allowAnonymousLocalhost: true` + loopback succeeds) and the remote-unauthenticated denial. There is no test for the security-critical negatives: anonymous + loopback when `AllowAnonymousLocalhost` is `false` must be denied, and anonymous + non-loopback when the flag is `true` must still be denied (the bypass is scoped strictly to loopback). Those are the misconfiguration cases that would expose the dashboard.
|
||||
|
||||
**Recommendation:** Add tests: anonymous + loopback + `allowAnonymousLocalhost: false` → not succeeded; anonymous + non-loopback + `allowAnonymousLocalhost: true` → not succeeded.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: confirmed the coverage gap and confirmed `DashboardAuthorizationHandler` already gates the bypass correctly on `AllowAnonymousLocalhost && IsLoopbackRequest()` (no product bug). Added two `DashboardAuthorizationHandlerTests`: `HandleAsync_AnonymousLocalhostDisallowed_DoesNotSucceed` (anonymous + loopback + `allowAnonymousLocalhost: false` → not succeeded) and `HandleAsync_AnonymousLocalhostAllowedFromRemoteAddress_DoesNotSucceed` (anonymous + non-loopback + `allowAnonymousLocalhost: true` → not succeeded, proving the bypass stays scoped to loopback). Both pass.
|
||||
|
||||
### Tests-011
|
||||
|
||||
@@ -187,13 +189,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs:233-301` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `GatewayEndToEndFakeWorkerSmokeTests` correctly stores and awaits `launcher.WorkerTask`, but `SessionWorkerClientFactoryFakeWorkerTests` uses `_ = RunWorkerAsync(...)` with no stored task (lines 152, 184, 220). An unhandled exception in the scripted worker becomes an unobserved `TaskException` that can surface as a process-level failure in an unrelated later test rather than failing the owning test.
|
||||
|
||||
**Recommendation:** Store the worker task and either await it during disposal or attach a continuation that fails the test on fault, mirroring `GatewayEndToEndFakeWorkerSmokeTests`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: confirmed all three scripted launchers in `SessionWorkerClientFactoryFakeWorkerTests` discarded the worker task. Added an `IWorkerTaskLauncher` interface (each launcher now stores its scripted task in a `WorkerTask` property and exposes `ObserveWorkerTaskAsync`); the test class now implements `IAsyncDisposable`, tracks every launcher it creates via a `Track` helper, and in `DisposeAsync` awaits each `WorkerTask` (within `TestTimeout`) so a scripted-worker fault fails the owning test instead of leaking as an unobserved `TaskScheduler.UnobservedTaskException`. `OperationCanceledException` and `IOException` — the expected outcomes of the worker client tearing the pipe down — are swallowed; anything else rethrows. `NeverReadyWorkerProcessLauncher` (which parks on an infinite `Task.Delay`) was given its own `CancellationTokenSource` so disposal can cancel and observe the parked task. Suite green.
|
||||
|
||||
### Tests-012
|
||||
|
||||
@@ -202,10 +204,10 @@
|
||||
| Severity | Low |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs:62`, `src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs:472` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Pipe names are uniquified per test with a GUID (good), but xUnit runs test classes in parallel by default and there is no `xunit.runner.json` or collection configuration. Tests that build a full `WebApplication` bind ephemeral ports (`--urls=http://127.0.0.1:0`, fine) but spin up DI containers and hosted services concurrently. Currently safe, but a future test binding a fixed port would silently collide.
|
||||
|
||||
**Recommendation:** Add an `xunit.runner.json` or a collection grouping the `WebApplication`-building tests, and keep the `:0` ephemeral-port convention explicit so future tests do not introduce a fixed-port collision.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** Resolved 2026-05-18: added `src/MxGateway.Tests/xunit.runner.json` making the parallelism policy explicit (`parallelizeTestCollections: true`, `maxParallelThreads: -1`, `parallelizeAssembly: false`, `longRunningTestSeconds: 30`) and wired it into `MxGateway.Tests.csproj` as `<None Update="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />` so the runner picks it up (confirmed present in `bin/Debug/net10.0/`). Added a comment at the only `WebApplication`-building call site (`GatewayApplicationTests.cs`, `--urls=http://127.0.0.1:0`) documenting that the ephemeral-port (`:0`) convention is mandatory because test collections run in parallel. No fixed-port binding exists today; this is a preventative guardrail as the finding recommends.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-18 |
|
||||
| Commit reviewed | `6c64030` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 8 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -138,13 +138,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs:175-182` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `Redactor_WithCredentialBearingValueFields_RedactsBeforeLogging` lives in `VariantConverterTests` but asserts on `WorkerLogRedactor.RedactValue`, which has nothing to do with `VariantConverter`. It is also a near-duplicate of coverage in `WorkerLogRedactorTests`. Placing redaction coverage inside the variant-converter class is misleading.
|
||||
|
||||
**Recommendation:** Move this test into `Bootstrap/WorkerLogRedactorTests.cs` (which already exists and tests `RedactFields`).
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — The misplaced redaction test was removed from `VariantConverterTests.cs` and re-added to `Bootstrap/WorkerLogRedactorTests.cs` as `RedactValue_WithCredentialBearingFieldNames_ReturnsRedactedValue` — alongside the existing `RedactFields` coverage, where redaction tests belong. Confirmed root cause: the old test asserted only on `WorkerLogRedactor.RedactValue` and never touched `VariantConverter`. The now-orphaned `using MxGateway.Worker.Bootstrap;` was removed from `VariantConverterTests.cs` (`TreatWarningsAsErrors`). The new home is `RedactValue` per-field coverage; `WorkerLogRedactorTests.RedactFields_...` already covers the dictionary path, so the two are complementary rather than duplicates.
|
||||
|
||||
### Worker.Tests-009
|
||||
|
||||
@@ -153,13 +153,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `src/MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs`, `AlarmDispatcherTests.cs`, `AlarmCommandExecutorTests.cs`, `AlarmRecordTransitionMapperTests.cs`, `WnWrapAlarmConsumerXmlTests.cs` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The alarm-related test files use `snake_case` method names while the rest of the project uses the `Method_State_Result` PascalCase convention. `docs/style-guides/CSharpStyleGuide.md` and the surrounding code establish PascalCase as the project convention; the alarm files diverge.
|
||||
|
||||
**Recommendation:** Rename alarm-test methods to the `Method_Scenario_Expectation` PascalCase form for one consistent convention.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Renamed every `[Fact]`/`[Theory]` method in the five alarm test files from `snake_case` to the project's `Method_Scenario_Expectation` PascalCase form (46 test methods total: 10 in `AlarmCommandHandlerTests`, 8 in `AlarmDispatcherTests`, 12 in `AlarmCommandExecutorTests`, 8 in `AlarmRecordTransitionMapperTests`, 9 in `WnWrapAlarmConsumerXmlTests` minus the existing PascalCase probe methods). Only test methods were renamed — `snake_case` is not present; the method names that *look* like helpers (`Subscribe`, `PollOnce`, `Dispose` on the fake doubles) are interface implementations of `IAlarmCommandHandler`/`IAlarmTransitionConsumer`/`IDisposable` and were correctly left unchanged. The suite stays green; xUnit discovers tests by attribute, not name, so the renames are behaviour-neutral.
|
||||
|
||||
### Worker.Tests-010
|
||||
|
||||
@@ -168,13 +168,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs:230-258` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `StartAsync_WithoutAlarmCommandHandlerFactory_SubscribeAlarmsReturnsInvalidRequest` asserts `Assert.Contains("alarm", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase)`. The XML doc claims it verifies the diagnostic says "alarm consumer not configured", but the assertion only checks the substring "alarm" — which would also match an unrelated message like "invalid alarm GUID". The assertion is weaker than the documented intent.
|
||||
|
||||
**Recommendation:** Assert the full diagnostic phrase so the test fails if the diagnostic regresses to a misleading message.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — The weak `Assert.Contains("alarm", ...)` was replaced with an exact `Assert.Equal` against the diagnostic the executor actually emits. Re-triage: the test's XML doc claimed the phrase was "alarm consumer not configured", but `MxAccessCommandExecutor.ExecuteSubscribeAlarms` (verified in `src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs:310-315`) produces "SubscribeAlarms requires an alarm command handler; the worker was constructed without one." — the doc was wrong, so both the assertion and the XML doc were corrected to the real phrase. The test now fails if the diagnostic regresses to any other message.
|
||||
|
||||
### Worker.Tests-011
|
||||
|
||||
@@ -183,13 +183,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs:92-112` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `DispatchAsync_WhenCanceledAfterExecutionStarts_StillReturnsLateReply` is named and documented as if it proves cancellation arrived after execution began. The test does `Started.Wait(...)` then `cancellation.Cancel()`, which proves execution started, but because the executor is already running on the STA the cancellation is inherently a no-op — the test cannot distinguish "cancel was observed and ignored" from "cancel was never checked". The name overstates what is proven.
|
||||
|
||||
**Recommendation:** Either tighten the test (assert the dispatcher's cancel path was reached and declined) or rename/comment it to "cancellation cannot abort an in-flight STA command", matching `gateway.md`'s stated behavior.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Took the rename/re-document option. The test is renamed `DispatchAsync_WhenCanceledWhileExecuting_DoesNotAbortInFlightCommand` and its XML doc rewritten to state exactly what it proves — an in-flight STA command is *not* aborted by cancellation — and to state explicitly that the test cannot and does not distinguish "cancel observed and ignored" from "cancel never checked". The doc now cites `gateway.md`'s wording ("cannot safely abort an in-flight COM call on the STA"). The test body is unchanged: it already asserts the command runs to completion and returns its normal `Ok` reply, which is the genuine behaviour. No runtime behaviour changed.
|
||||
|
||||
### Worker.Tests-012
|
||||
|
||||
@@ -198,13 +198,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Location | `src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `docs/WorkerFrameProtocol.md` states the reader "rejects zero-length payloads and payloads larger than the configured maximum (default 16 MiB) before allocating the payload buffer." `WorkerFrameProtocolTests` covers malformed-length, wrong protocol version, wrong session, and malformed payload, but has no test for the zero-length-payload rejection or the oversized-frame rejection — both explicit security-relevant input-validation paths.
|
||||
|
||||
**Recommendation:** Add tests feeding a frame with `payload_length == 0` and one with `payload_length` above the configured maximum, asserting the corresponding `WorkerFrameProtocolErrorCode`.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Re-triage of the zero-length half: the finding's "no test for the zero-length-payload rejection" is partly inaccurate. The pre-existing `ReadAsync_WithMalformedLength_ThrowsMalformedLength` fed a four-zero-byte stream — which is exactly a frame declaring `payload_length == 0` — so the zero-length path *was* already covered, just under a misleading name (the length prefix itself is well-formed; only the declared length is zero). That test was renamed `ReadAsync_WithZeroLengthPayload_ThrowsMalformedLength` with an XML doc explaining the four-zero-byte construction, rather than adding a duplicate. The oversized half was a genuine gap: a new `ReadAsync_WithPayloadAboveConfiguredMaximum_ThrowsMessageTooLarge` constructs `WorkerFrameProtocolOptions` with a 64-byte maximum, feeds a length prefix of 65, and asserts `WorkerFrameProtocolErrorCode.MessageTooLarge` — verified against `WorkerFrameReader.ReadAsync`, both checks fire before the payload buffer is rented. The small configured maximum keeps the test from allocating a multi-megabyte buffer.
|
||||
|
||||
### Worker.Tests-013
|
||||
|
||||
@@ -213,13 +213,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs:539-546` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `ThrowIfCompletedAsync` does an unconditional `await Task.Delay(TimeSpan.FromMilliseconds(100))` then checks `task.IsCompleted`. This adds a fixed 100 ms to the test and only catches a `RunAsync` that fails within that arbitrary window; a session that faults after 100 ms slips past undetected.
|
||||
|
||||
**Recommendation:** Replace with a deterministic race: `await Task.WhenAny(runTask, <first-expected-frame-read>)` and assert the run task did not win.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — `ThrowIfCompletedAsync` was deleted (it had a single call site, in `RunAsync_SendsHeartbeatPayloadFromRuntimeSnapshot`). That test now races `runTask` against the first-heartbeat `ReadUntilAsync` with `Task.WhenAny`; if `runTask` wins it is awaited to surface the underlying fault and the test fails via `Assert.Fail`. The fixed 100 ms delay is gone — the check is now deterministic: a `RunAsync` faulting at *any* time before the first heartbeat is caught, and a healthy run completes as soon as the heartbeat arrives instead of always paying 100 ms.
|
||||
|
||||
### Worker.Tests-014
|
||||
|
||||
@@ -228,13 +228,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs:194`, `WorkerPipeSessionTests.cs:622`, `Sta/StaCommandDispatcherTests.cs:348`, `MxAccess/MxAccessStaSessionTests.cs:334`, `MxAccess/MxAccessCommandExecutorTests.cs:1124` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `FakeRuntimeSession`, `NoopComApartmentInitializer`, `NoopEventSink`/`NullEventSink`, and the `CreateFrame`/`WriteUInt32LittleEndian` helpers are re-implemented independently in multiple test files. The two `FakeRuntimeSession` implementations have already diverged (one supports `BlockDispatch`/event enqueue, one does not), and `NoopComApartmentInitializer` is defined four times.
|
||||
|
||||
**Recommendation:** Extract shared test doubles (`NoopComApartmentInitializer`, frame helpers, a single configurable `FakeRuntimeSession`) into a `TestSupport` folder/namespace consumed by all test classes.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Added a `src/MxGateway.Worker.Tests/TestSupport/` folder (namespace `MxGateway.Worker.Tests.TestSupport`) with four shared doubles: `NoopComApartmentInitializer`, `NoopEventSink`, `WorkerFrameTestHelpers` (`CreateFrame`/`WriteUInt32LittleEndian`), and a single configurable `FakeRuntimeSession`. The consolidated `FakeRuntimeSession` is the richer of the two divergent copies (it supports `BlockDispatch`, event enqueue, shutdown-timeout, and throw-after-release); the minimal `WorkerPipeClientTests` caller simply leaves the options unset. The per-file copies were deleted from `WorkerPipeClientTests`, `WorkerPipeSessionTests`, `StaCommandDispatcherTests`, `MxAccessStaSessionTests`, `MxAccessCommandExecutorTests`, and `WorkerFrameProtocolTests`, and the orphaned `NullEventSink` in `AlarmCommandExecutorTests` was replaced with the shared `NoopEventSink`. Re-triage: the finding says `NoopComApartmentInitializer` "is defined four times" — it was defined **three** times (`StaCommandDispatcherTests`, `MxAccessStaSessionTests`, `MxAccessCommandExecutorTests`); the fourth alarm-area `IStaComApartmentInitializer` implementation is `StaRuntimeTests.RecordingComApartmentInitializer`, which is a *recording* double (asserts init/uninit ordering), not a no-op, so it was deliberately left in place rather than folded into the shared no-op. Unused `using` directives left behind by the removals were stripped (`TreatWarningsAsErrors`).
|
||||
|
||||
### Worker.Tests-015
|
||||
|
||||
@@ -243,10 +243,10 @@
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Location | `src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `MxAccessEventQueueTests` covers monotonic sequencing, drain, capacity overflow, and first-fault-wins, but does not cover `Drain` with `maxEvents: 0` (drain-all) — a branch `FakeRuntimeSession.DrainEvents` even special-cases — nor draining an empty queue, nor enqueue after a manual `RecordFault`. These are minor branches but the overflow/fault interaction is the worker's backpressure contract.
|
||||
|
||||
**Recommendation:** Add a `Drain(0)` drain-all test and an empty-queue drain test.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Added three tests to `MxAccessEventQueueTests`. `Drain_WithZeroMaxEvents_DrainsAllEvents` covers the `maxEvents == 0` drain-all branch in `MxAccessEventQueue.Drain` (verified at `src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs:174`) — three events enqueued, `Drain(0)` returns all three in order and empties the queue. `Drain_WhenQueueIsEmpty_ReturnsEmptyList` covers the `drainCount == 0` early-return branch for both `Drain(0)` and `Drain(5)` on an empty queue. `Enqueue_AfterRecordFault_ThrowsInvalidOperationException` covers the backpressure contract gap the finding flagged — after a manual `RecordFault`, `Enqueue` throws `InvalidOperationException` ("outbound event queue is faulted") and the event is not queued.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-18 |
|
||||
| Commit reviewed | `6c64030` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 7 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -157,13 +157,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Performance & resource management |
|
||||
| Location | `src/MxGateway.Worker/Ipc/WorkerFrameReader.cs:31,49`, `src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs:57-58` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Every frame read allocates a fresh 4-byte length buffer and a payload `byte[]`; every write allocates `ToByteArray()` plus a 4-byte prefix. On the hot event-drain path (batches of up to 128 `WorkerEvent` frames every 25 ms) this produces steady gen-0 garbage. `WorkerFrameWriter` also effectively serializes twice (`CalculateSize()` then `ToByteArray()`).
|
||||
|
||||
**Recommendation:** Reuse a pooled buffer / `ArrayPool<byte>` for the length prefix and payload, and write directly into a pooled buffer using `CodedOutputStream`. Low priority unless event throughput is high.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — `WorkerFrameWriter.WriteAsync` now serializes the envelope exactly once into a single frame buffer that carries the 4-byte length prefix followed by the payload, via `envelope.WriteTo(new Span<byte>(frame, sizeof(uint), payloadLength))`. This eliminates the redundant second serialization pass (`ToByteArray()` re-runs `CalculateSize()` internally), the separate length-prefix array, and the separate prefix `WriteAsync`/extra `FlushAsync` round. `WorkerFrameReader.ReadAsync` now rents its payload buffer from `ArrayPool<byte>.Shared` and returns it in a `finally` once `WorkerEnvelope.Parser.ParseFrom(payload, 0, length)` has copied what it needs; `ReadExactlyOrThrowAsync` gained an explicit `count` parameter so it honours the logical frame length rather than the (possibly larger) rented buffer length. The 4-byte length-prefix buffer is left as a per-call stack-sized allocation — pooling a 4-byte array is not worthwhile. Verified by the new regression test `WorkerFrameProtocolTests.ReadAsync_WithVaryingFrameSizes_ParsesEachFrameExactly`, which reads a large frame followed by a small frame through one reader to prove the pooled buffer is sliced to each frame's own length and never leaks stale trailing bytes; the existing round-trip, malformed-payload, and concurrent-write tests continue to pass.
|
||||
|
||||
### Worker-010
|
||||
|
||||
@@ -172,13 +172,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/MxGateway.Worker/Conversion/VariantConverter.cs:204-226` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `ConvertInt64Scalar` is reached for `TypeCode.UInt32` and `TypeCode.Int64`. For a `uint` with `expectedDataType == MxDataType.Time`, the value is treated as a Windows `FILETIME` via `DateTime.FromFileTimeUtc(longValue)`; a 32-bit FILETIME is never a valid full FILETIME, so this silently produces a near-epoch timestamp rather than a raw/diagnostic value. Unlikely in practice but a silent misconversion.
|
||||
|
||||
**Recommendation:** Only apply the `MxDataType.Time` FILETIME projection for 64-bit source types; for `uint` fall through to integer or raw.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — `ConvertInt64Scalar`'s `MxDataType.Time` FILETIME projection is now gated on `value is long`. A genuine 64-bit `long` still projects to a `Timestamp` via `DateTime.FromFileTimeUtc`; a 32-bit `uint` — which can only hold the low half of a FILETIME — now falls through to the integer projection (`DataType = Integer`, `Int64Value`) instead of silently producing a bogus near-1601 timestamp. Verified by the regression test `VariantConverterTests.Convert_WithUInt32AndExpectedTime_DoesNotProjectFileTime`; the existing `Convert_WithFileTimeAndExpectedTime_ProjectsTimestamp` (a `long` FILETIME) continues to pass, confirming the 64-bit path is unchanged.
|
||||
|
||||
### Worker-011
|
||||
|
||||
@@ -187,13 +187,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/MxGateway.Worker/Ipc/WorkerPipeClient.cs:169-171` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `retryAttempts` is computed as `(connectTimeout / min(connectTimeout, attemptTimeout)) - 1`. With defaults (30000 / 2000) this yields 14 retries, but each retry also incurs Polly exponential backoff. The overall `connectDeadline` (`CancelAfter(connectTimeout)`) is the real bound, so the computed attempt count can be larger or smaller than the time budget allows, and the formula is opaque.
|
||||
|
||||
**Recommendation:** Drive retries purely off the `connectDeadline` token (Polly stops when cancelled) and drop the fragile attempt-count arithmetic, or add a comment explaining the intent.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — The opaque `retryAttempts` arithmetic in `ConnectWithRetryAsync` was removed. `MaxRetryAttempts` is now `int.MaxValue`, so the retry loop is bounded solely by the `connectDeadline` linked token (`CancelAfter(_connectTimeoutMilliseconds)`): Polly stops retrying the moment that token is cancelled, making the overall connect timeout the single source of truth and correctly accounting for the exponential backoff between attempts (which the old formula ignored). A comment documents the intent. No new test was added — the change does not alter observable behavior (the deadline was always the real bound; the old formula always permitted more attempts than fit the budget), and the existing `WorkerPipeClientTests.RunAsync_RetriesUntilPipeServerAppears` (server appears mid-retry) and `RunAsync_WhenPipeNeverAppears_ThrowsTimeoutException` (deadline ends the loop) already cover both retry-until-success and deadline-bounded termination.
|
||||
|
||||
### Worker-012
|
||||
|
||||
@@ -202,13 +202,15 @@
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs:44-55`, `src/MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs:38-43`, `src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs:106-112` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Multiple comments describe the alarm path as not-yet-wired future work ("PR A.2 — COM-side subscription scaffold … the worker advertises no alarm subscription", "the worker bootstrap will gain a thin 'run-on-STA' wrapper as part of A.3"). As of commit 6c64030 the alarm command handler, STA poll loop, and `SubscribeAlarms`/`AcknowledgeAlarm`/`QueryActiveAlarms` are all wired. These comments are stale and misleading.
|
||||
|
||||
**Recommendation:** Update the XML docs/comments to describe the shipped behavior; remove the "future PR" framing.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Re-triage:** The `WnWrapAlarmConsumer.cs:38-43` citation is inaccurate — those lines were rewritten by Worker-001 and already describe the shipped no-internal-timer threading model correctly; nothing stale there. Conversely, two stale comments the finding did *not* cite were found on the same alarm path and fixed under the same root cause: `AlarmDispatcher.cs`'s `<remarks>` still framed the dispatcher as "the in-process slice of A.3" with a "companion follow-up PR" adding the (now-shipped) `SubscribeAlarmsCommand`/`AcknowledgeAlarmCommand`/`QueryActiveAlarmsCommand`, and stated the consumer "polls on a `System.Threading.Timer` thread today" — a claim made false by Worker-001's removal of that timer; and `AlarmCommandHandler.cs`'s `<remarks>` likewise asserted "the wnwrap consumer's polling timer fires on a thread-pool thread". The discovery document `docs/AlarmClientDiscovery.md` (referenced by the source comments) was deliberately left untouched: it is a historical research log of the investigation that chose the shipped design, not API/contract/lifecycle prose, and the source comments cite only its still-accurate "Option A — captured" payload schema.
|
||||
|
||||
**Resolution:** 2026-05-18 — Rewrote the stale alarm-path comments to describe shipped behavior with no "future PR / A.2 / A.3" framing. `MxAccessAlarmEventSink`: the class `<remarks>` and the `Attach` comment now explain that `AlarmDispatcher` owns the consumer→sink→queue wire-up and that `Attach` carries only the session id (no COM-event subscription is needed because the polled wnwrap consumer raises transition events itself). `MxAccessEventMapper.CreateOnAlarmTransition`'s XML summary now states the worker drives it from `MxAccessAlarmEventSink.EnqueueTransition` once `AlarmDispatcher` decodes a wnwrap transition. `AlarmDispatcher` and `AlarmCommandHandler` `<remarks>` were corrected to describe the shipped command surface and the no-internal-timer / STA-driven polling model (the `System.Threading.Timer` claims were factually wrong post-Worker-001). Pure documentation change — no behavior altered, no test needed; the build stays green.
|
||||
|
||||
### Worker-013
|
||||
|
||||
@@ -217,13 +219,15 @@
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Location | `src/MxGateway.Worker/Sta/StaMessagePump.cs` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `StaMessagePump` — the heart of COM event delivery (`MsgWaitForMultipleObjectsEx` + `PeekMessage`/`DispatchMessage`) — has no direct unit tests. `StaRuntimeTests` exercises it indirectly for command wake-up but never verifies that a posted Windows message actually wakes the wait and is dispatched, nor that `PumpPendingMessages` returns a correct count. The alarm poll-loop lifecycle in `MxAccessStaSession` (start/cancel/await on shutdown) also has no test. These are the most failure-sensitive paths in the module.
|
||||
|
||||
**Recommendation:** Add tests that post a message to the STA thread and assert it is pumped, and tests covering alarm poll-loop start/stop and shutdown ordering.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Re-triage:** This finding is stale as of the reviewed branch — the coverage it asks for already exists. `src/MxGateway.Worker.Tests/Sta/StaMessagePumpTests.cs` contains direct `StaMessagePump` tests covering null-argument validation, waking on a signalled event, returning on timeout, the zero-timeout conversion branch, `PumpPendingMessages` returning the correct count for messages posted to the STA thread (`PumpPendingMessages_MessagesPostedToStaThread_ReturnsCountProcessed`, `PumpPendingMessages_NoMessagesPosted_ReturnsZero`), and `WaitForWorkOrMessages` waking on a posted Windows message (`WaitForWorkOrMessages_WindowsMessagePosted_ReturnsForInputAvailable`) — exactly the "post a message and assert it is pumped" test the recommendation asks for. The alarm poll-loop lifecycle is covered by `MxAccessStaSessionTests.StartAsync_WithAlarmCommandHandlerFactory_PollOnceCalledViaSta` (start → poll runs on the STA) and `Dispose_StopsAlarmPollLoop` (Dispose joins the poll task; no further polls). The finding was raised against a stale view of the test project; no source or test change is required. Re-triaged as already resolved rather than fixed.
|
||||
|
||||
**Resolution:** 2026-05-18 — No code change. Re-triaged: the requested direct `StaMessagePump` tests (including posted-message dispatch and pump count) and the alarm poll-loop start/stop lifecycle tests already exist in `StaMessagePumpTests.cs` and `MxAccessStaSessionTests.cs`. See the re-triage note above for the specific test names.
|
||||
|
||||
### Worker-014
|
||||
|
||||
@@ -232,13 +236,13 @@
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `src/MxGateway.Worker/MxAccess/AlarmCommandHandler.cs:33`, `:202` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The file declares two public types — the `AlarmCommandHandler` class and the `IAlarmCommandHandler` interface. The C# style guide and the rest of the module follow one-public-type-per-file (e.g. interfaces in their own `I*.cs` files like `IMxAccessAlarmConsumer.cs`).
|
||||
|
||||
**Recommendation:** Move `IAlarmCommandHandler` to its own `IAlarmCommandHandler.cs` for consistency.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — The `IAlarmCommandHandler` interface (with its XML docs) was moved verbatim out of `AlarmCommandHandler.cs` into a new `src/MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs`, with its own `using` directives (`System`, `System.Collections.Generic`, `MxGateway.Contracts.Proto`). `AlarmCommandHandler.cs` now declares one public type, matching the module's one-public-type-per-file convention (cf. `IMxAccessAlarmConsumer.cs`). Pure file-organization change — no API surface, behavior, or namespace changed; no test needed. The worker build is clean with zero warnings (no unused usings left behind in `AlarmCommandHandler.cs`).
|
||||
|
||||
### Worker-015
|
||||
|
||||
@@ -247,10 +251,10 @@
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs:115-145` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** On overflow, `Enqueue` records the overflow fault and throws `MxAccessEventQueueOverflowException`; `MxAccessBaseEventSink.EnqueueEvent` catches it and calls `RecordFault` again. `RecordFault` is a no-op when a fault already exists, so the second call is harmless — but the intent is muddled, and there is no test asserting the dropped-event behavior. This is acceptable per the fail-fast design but undocumented at the call site.
|
||||
|
||||
**Recommendation:** Add a brief comment in `EnqueueEvent` clarifying that an overflow exception is expected and already self-records its fault, so the catch is intentionally a near no-op.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — Added a comment in `MxAccessBaseEventSink.EnqueueEvent`'s catch block (per the finding's recommendation) explaining that two distinct fail-fast failures land there: a conversion failure from `createEvent()` (recorded here as an `MxaccessEventConversionFailed` fault) and an `MxAccessEventQueueOverflowException` from `Enqueue` at capacity, which — per the fail-fast backpressure design in `docs/DesignDecisions.md` — drops the event and has *already* self-recorded a `QueueOverflow` fault inside `Enqueue`. Because `MxAccessEventQueue.RecordFault` keeps only the first fault, the catch's `RecordFault` call is then a deliberate near no-op rather than a second, conflicting fault. Pure comment change as recommended — no behavior altered. `docs/DesignDecisions.md` already documents the fail-fast event backpressure rule, so no doc change was required.
|
||||
|
||||
@@ -776,6 +776,28 @@ case, to distinguish the two acks. `WorkerAlarmRpcDispatcher` reads only
|
||||
the top-level `hresult`/`protocol_status`, so it handles both arms
|
||||
without unpacking the payload.
|
||||
|
||||
**Worker `native_status` → public `AcknowledgeAlarmReply` mapping.** The
|
||||
worker carries the ack outcome as a single `int32`
|
||||
(`AcknowledgeAlarmReplyPayload.native_status`, the `AlarmAckByName` /
|
||||
`AlarmAckByGUID` return code; `0` = success), also mirrored into the
|
||||
worker `MxCommandReply.hresult`. The public `AcknowledgeAlarmReply` has
|
||||
two outcome-shaped fields, but only one is populated:
|
||||
|
||||
- `AcknowledgeAlarmReply.hresult` — `WorkerAlarmRpcDispatcher` copies the
|
||||
worker's `MxCommandReply.hresult` (the native return code) into this
|
||||
field. **This is the authoritative ack-outcome field**; `0` means the
|
||||
ack succeeded. It is absent only when the worker reply omitted the
|
||||
value, which is a protocol violation surfaced in `protocol_status`.
|
||||
- `AcknowledgeAlarmReply.status` (`MxStatusProxy`) — the worker by-name /
|
||||
by-GUID ack path produces only the `int32` return code, never a
|
||||
populated `MXSTATUS_PROXY` struct, so `WorkerAlarmRpcDispatcher` leaves
|
||||
this field **unset on every reply**. It is reserved for a future
|
||||
structured view of the ack outcome. Clients must not depend on it.
|
||||
|
||||
Client authors should therefore branch on `protocol_status` first (for
|
||||
transport/session-level failures) and then on `hresult` (`0` = ack
|
||||
accepted by MXAccess) — never on `status`.
|
||||
|
||||
### 5. STA / threading — production fix needed
|
||||
|
||||
The wnwrap COM is `ThreadingModel=Apartment`. The consumer's
|
||||
|
||||
+11
-18
@@ -107,29 +107,20 @@ The gateway keeps API key state in a dedicated SQLite database. SQLite is suffic
|
||||
|
||||
### Connection factory
|
||||
|
||||
`AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and opens the connection in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning:
|
||||
`AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and builds a connection string in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning. Connection pooling is enabled and the connection string carries a non-zero `DefaultTimeout`:
|
||||
|
||||
```csharp
|
||||
public SqliteConnection CreateConnection()
|
||||
SqliteConnectionStringBuilder builder = new()
|
||||
{
|
||||
string sqlitePath = options.Value.Authentication.SqlitePath;
|
||||
string? directory = Path.GetDirectoryName(sqlitePath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
SqliteConnectionStringBuilder builder = new()
|
||||
{
|
||||
DataSource = sqlitePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate
|
||||
};
|
||||
|
||||
return new SqliteConnection(builder.ToString());
|
||||
}
|
||||
DataSource = sqlitePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Pooling = true,
|
||||
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
|
||||
};
|
||||
```
|
||||
|
||||
Every store opens its connection through `OpenConnectionAsync`, which opens the connection and then applies `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout`. WAL is a persistent database-level setting so re-applying it per connection is a cheap no-op; `busy_timeout` is per-connection state. Because `MarkKeyUsedAsync` runs on every authenticated request and `SqliteApiKeyAuditStore` appends on every denial, this lets concurrent readers and writers retry briefly instead of surfacing `SQLITE_BUSY` as a hard failure on the request path.
|
||||
|
||||
### Schema
|
||||
|
||||
`SqliteAuthSchema` declares table names and the current schema version as constants. Three tables are involved:
|
||||
@@ -166,6 +157,8 @@ public static ApiKeyRecord Read(SqliteDataReader reader)
|
||||
|
||||
`SqliteApiKeyAdminStore` (`IApiKeyAdminStore`) implements administrative mutations: `CreateAsync` accepts an `ApiKeyCreateRequest`, `RevokeAsync` sets `revoked_utc` only when not already revoked, and `RotateAsync` replaces `secret_hash`, clears `last_used_utc`, and clears `revoked_utc` so a rotated key is immediately usable.
|
||||
|
||||
Because `RotateAsync` clears `revoked_utc`, rotating a previously revoked key reactivates it. The dashboard API Keys page therefore offers the Rotate (and Revoke) action only for keys whose status is `Active`; a revoked key shows no actions, so an operator cannot un-revoke a deliberately disabled key as a side effect of a rotation.
|
||||
|
||||
### Audit trail
|
||||
|
||||
`SqliteApiKeyAuditStore` (`IApiKeyAuditStore`) appends `ApiKeyAuditEntry` values to the `api_key_audit` table and stamps each row with a UTC timestamp inside the store rather than trusting the caller. `ListRecentAsync` returns the most recent rows ordered by `audit_id` descending and projects them into `ApiKeyAuditRecord`. Rows are kept even after the referenced key is revoked because the audit history is the durable record of administrative action; the `key_id` column is nullable to accommodate non-key-scoped events such as `init-db`.
|
||||
|
||||
+12
-2
@@ -10,7 +10,7 @@ The layer is composed of four collaborators:
|
||||
|
||||
| Type | Lifetime | Role |
|
||||
|------|----------|------|
|
||||
| `MxAccessGatewayService` | scoped (gRPC) | Implements the four `MxAccessGateway` RPCs, performs exception mapping. |
|
||||
| `MxAccessGatewayService` | scoped (gRPC) | Implements the six `MxAccessGateway` RPCs, performs exception mapping. |
|
||||
| `MxAccessGrpcRequestValidator` | singleton | Rejects malformed requests before any session work runs. |
|
||||
| `MxAccessGrpcMapper` | singleton | Converts public proto types to internal `WorkerCommand`/`WorkerEvent` types and back. |
|
||||
| `IEventStreamService` (`EventStreamService`) | singleton | Owns the event stream pipeline, including bounded queue and backpressure handling. |
|
||||
@@ -29,7 +29,7 @@ A second gRPC service, `GalaxyRepositoryGrpcService`, is mapped alongside it. It
|
||||
|
||||
## RPC Handlers
|
||||
|
||||
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
|
||||
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto` — six in total: `OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, and `QueryActiveAlarms`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
|
||||
|
||||
Public gRPC send and receive message sizes are configured from
|
||||
`MxGateway:Protocol:MaxGrpcMessageBytes` (default 16 MiB). Official clients use
|
||||
@@ -86,6 +86,14 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
|
||||
|
||||
`StreamEvents` is a server-streaming RPC. The handler delegates the full pipeline to `IEventStreamService` and just forwards each `MxEvent` onto the response stream. Keeping the channel and producer/consumer machinery out of the handler means cancellation, exception mapping, and metric bookkeeping live in one place.
|
||||
|
||||
### `AcknowledgeAlarm`
|
||||
|
||||
`AcknowledgeAlarm` is a unary RPC that acknowledges a single alarm. The handler validates `session_id` and `alarm_full_reference` inline (it does not run through `MxAccessGrpcRequestValidator`, because the alarm surface routes through `IAlarmRpcDispatcher` rather than the generic `Invoke` path), resolves the session, then delegates to the registered `IAlarmRpcDispatcher`. The production `WorkerAlarmRpcDispatcher` routes the ack over the worker IPC by GUID (`AcknowledgeAlarmCommand`) when the reference parses as a canonical GUID, or by `Provider!Group.Tag` reference (`AcknowledgeAlarmByNameCommand`) otherwise. The handler-level RPC behaviour and the alarm contract itself are documented in [Alarm Client Discovery](./AlarmClientDiscovery.md).
|
||||
|
||||
### `QueryActiveAlarms`
|
||||
|
||||
`QueryActiveAlarms` is a server-streaming RPC that returns an `ActiveAlarmSnapshot` per currently active alarm. The handler validates `session_id` inline, resolves the session, and delegates to `IAlarmRpcDispatcher`; `WorkerAlarmRpcDispatcher` issues a `QueryActiveAlarmsCommand` over the worker IPC and streams each snapshot from the worker reply.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
`MxAccessGrpcRequestValidator` rejects requests with `StatusCode.InvalidArgument` before any session work happens. The rules are intentionally narrow — anything that requires session state (for example, "session does not exist") is left for `ISessionManager` so the validator can stay synchronous and side-effect free.
|
||||
@@ -96,6 +104,8 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
|
||||
| `CloseSession` | `session_id` must be non-empty. | `InvalidArgument` |
|
||||
| `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` |
|
||||
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
|
||||
| `AcknowledgeAlarm` | `session_id` and `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
|
||||
| `QueryActiveAlarms` | `session_id` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
|
||||
|
||||
The payload-vs-kind check matters because the `MxCommand.payload` oneof is non-discriminated on the wire — a misaligned client could send `kind = Write` with a `Register` payload and silently confuse the worker. The validator turns that into a clear client error:
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
namespace MxGateway.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes version metadata shared by gateway components before generated
|
||||
/// protobuf contracts are introduced.
|
||||
/// Holds the protocol version constants shared by gateway components.
|
||||
/// <see cref="GatewayProtocolVersion"/> is advertised to clients in
|
||||
/// <c>OpenSessionReply</c>; <see cref="WorkerProtocolVersion"/> is used to
|
||||
/// validate <c>WorkerEnvelope</c> protocol framing on the gateway↔worker pipe.
|
||||
/// </summary>
|
||||
public static class GatewayContractInfo
|
||||
{
|
||||
|
||||
@@ -21418,7 +21418,12 @@ namespace MxGateway.Contracts.Proto {
|
||||
|
||||
private int hresult_;
|
||||
/// <summary>
|
||||
/// HRESULT captured from MXAccess if the ack failed at the COM layer.
|
||||
/// Native ack return code echoed from the worker. The worker carries the
|
||||
/// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
|
||||
/// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
|
||||
/// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
|
||||
/// ack-outcome field for the public RPC. Absent only when the worker reply
|
||||
/// omitted the value entirely (a protocol violation).
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
@@ -21446,7 +21451,11 @@ namespace MxGateway.Contracts.Proto {
|
||||
public const int StatusFieldNumber = 5;
|
||||
private global::MxGateway.Contracts.Proto.MxStatusProxy status_;
|
||||
/// <summary>
|
||||
/// Native MxAccess status describing the outcome of the ack.
|
||||
/// Reserved for a structured MxStatusProxy view of the ack outcome. The
|
||||
/// worker by-name/by-GUID ack path produces only the int32 return code
|
||||
/// (see `hresult`), so the current gateway leaves this field UNSET on every
|
||||
/// reply. Clients must read `hresult` (and `protocol_status`) for the ack
|
||||
/// result and must not depend on `status` being populated.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
@@ -22078,6 +22087,17 @@ namespace MxGateway.Contracts.Proto {
|
||||
/// <summary>Field number for the "success" field.</summary>
|
||||
public const int SuccessFieldNumber = 1;
|
||||
private int success_;
|
||||
/// <summary>
|
||||
/// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
|
||||
/// (a 16-bit signed value in the COM struct, widened to int32 on the
|
||||
/// wire). Despite the name it is NOT a boolean — it is the raw numeric
|
||||
/// indicator the worker reads off the COM struct without reinterpretation.
|
||||
/// It is carried verbatim for diagnostics; the authoritative success/
|
||||
/// failure of the operation is `category` (MX_STATUS_CATEGORY_OK marks
|
||||
/// success), with `detail`, `diagnostic_text`, `raw_category`, and
|
||||
/// `raw_detected_by` describing any non-OK outcome. Clients should branch
|
||||
/// on `category`, not on a specific `success` value.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public int Success {
|
||||
|
||||
@@ -7,6 +7,13 @@ option csharp_namespace = "MxGateway.Contracts.Proto.Galaxy";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/wrappers.proto";
|
||||
|
||||
// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
// additively only. Never renumber or repurpose an existing field number or
|
||||
// enum value. When a field or enum value is removed, add a `reserved` range
|
||||
// (and `reserved` name) covering it in the same change so a future editor
|
||||
// cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
// declarations today because no field or enum value has ever been removed.
|
||||
|
||||
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
// database). Lets clients enumerate the deployed object hierarchy and each
|
||||
// object's dynamic attributes so they know what tag references to subscribe
|
||||
|
||||
@@ -7,6 +7,13 @@ option csharp_namespace = "MxGateway.Contracts.Proto";
|
||||
import "google/protobuf/duration.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
// additively only. Never renumber or repurpose an existing field number or
|
||||
// enum value. When a field or enum value is removed, add a `reserved` range
|
||||
// (and `reserved` name) covering it in the same change so a future editor
|
||||
// cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
// declarations today because no field or enum value has ever been removed.
|
||||
|
||||
// Public client API for MXAccess sessions hosted by the gateway.
|
||||
service MxAccessGateway {
|
||||
rpc OpenSession(OpenSessionRequest) returns (OpenSessionReply);
|
||||
@@ -641,9 +648,18 @@ message AcknowledgeAlarmReply {
|
||||
string session_id = 1;
|
||||
string correlation_id = 2;
|
||||
ProtocolStatus protocol_status = 3;
|
||||
// HRESULT captured from MXAccess if the ack failed at the COM layer.
|
||||
// Native ack return code echoed from the worker. The worker carries the
|
||||
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
|
||||
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
|
||||
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
|
||||
// ack-outcome field for the public RPC. Absent only when the worker reply
|
||||
// omitted the value entirely (a protocol violation).
|
||||
optional int32 hresult = 4;
|
||||
// Native MxAccess status describing the outcome of the ack.
|
||||
// Reserved for a structured MxStatusProxy view of the ack outcome. The
|
||||
// worker by-name/by-GUID ack path produces only the int32 return code
|
||||
// (see `hresult`), so the current gateway leaves this field UNSET on every
|
||||
// reply. Clients must read `hresult` (and `protocol_status`) for the ack
|
||||
// result and must not depend on `status` being populated.
|
||||
MxStatusProxy status = 5;
|
||||
string diagnostic_message = 6;
|
||||
}
|
||||
@@ -657,6 +673,15 @@ message QueryActiveAlarmsRequest {
|
||||
}
|
||||
|
||||
message MxStatusProxy {
|
||||
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
|
||||
// (a 16-bit signed value in the COM struct, widened to int32 on the
|
||||
// wire). Despite the name it is NOT a boolean — it is the raw numeric
|
||||
// indicator the worker reads off the COM struct without reinterpretation.
|
||||
// It is carried verbatim for diagnostics; the authoritative success/
|
||||
// failure of the operation is `category` (MX_STATUS_CATEGORY_OK marks
|
||||
// success), with `detail`, `diagnostic_text`, `raw_category`, and
|
||||
// `raw_detected_by` describing any non-OK outcome. Clients should branch
|
||||
// on `category`, not on a specific `success` value.
|
||||
int32 success = 1;
|
||||
MxStatusCategory category = 2;
|
||||
MxStatusSource detected_by = 3;
|
||||
|
||||
@@ -8,6 +8,13 @@ import "google/protobuf/duration.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "mxaccess_gateway.proto";
|
||||
|
||||
// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
// additively only. Never renumber or repurpose an existing field number or
|
||||
// enum value. When a field or enum value is removed, add a `reserved` range
|
||||
// (and `reserved` name) covering it in the same change so a future editor
|
||||
// cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
// declarations today because no field or enum value has ever been removed.
|
||||
|
||||
// Gateway-to-worker IPC envelope. Named-pipe framing prepends a little-endian
|
||||
// uint32 payload length to this protobuf payload.
|
||||
message WorkerEnvelope {
|
||||
|
||||
@@ -6,6 +6,7 @@ using MxGateway.Server.Dashboard;
|
||||
|
||||
namespace MxGateway.IntegrationTests;
|
||||
|
||||
[Collection(LiveResourcesCollection.Name)]
|
||||
public sealed class DashboardLdapLiveTests
|
||||
{
|
||||
[LiveLdapFact]
|
||||
|
||||
@@ -2,6 +2,7 @@ using MxGateway.Server.Galaxy;
|
||||
|
||||
namespace MxGateway.IntegrationTests.Galaxy;
|
||||
|
||||
[Collection(LiveResourcesCollection.Name)]
|
||||
public sealed class GalaxyRepositoryLiveTests
|
||||
{
|
||||
/// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary>
|
||||
|
||||
@@ -18,11 +18,7 @@ public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether live Galaxy Repository tests are enabled.</summary>
|
||||
public static bool Enabled =>
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable(EnableVariableName),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
public static bool Enabled => IntegrationTestEnvironment.IsEnabled(EnableVariableName);
|
||||
|
||||
/// <summary>Gets the Galaxy Repository connection string from environment or default.</summary>
|
||||
public static string ConnectionString =>
|
||||
|
||||
@@ -9,9 +9,18 @@ public static class IntegrationTestEnvironment
|
||||
public const string LiveMxAccessEventTimeoutSecondsVariableName = "MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS";
|
||||
|
||||
/// <summary>Gets whether live MXAccess tests are enabled.</summary>
|
||||
public static bool LiveMxAccessTestsEnabled =>
|
||||
public static bool LiveMxAccessTestsEnabled => IsEnabled(LiveMxAccessVariableName);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether an opt-in live-test suite is enabled, by comparing the named
|
||||
/// environment variable to <c>1</c>. Shared by every <c>Live*FactAttribute</c>
|
||||
/// so the opt-in check has a single implementation.
|
||||
/// </summary>
|
||||
/// <param name="variableName">The environment variable that gates the suite.</param>
|
||||
/// <returns><see langword="true"/> when the variable is exactly <c>1</c>.</returns>
|
||||
public static bool IsEnabled(string variableName) =>
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable(LiveMxAccessVariableName),
|
||||
Environment.GetEnvironmentVariable(variableName),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
|
||||
@@ -12,9 +12,5 @@ public sealed class LiveLdapFactAttribute : FactAttribute
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Enabled =>
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable(EnableVariableName),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
public static bool Enabled => IntegrationTestEnvironment.IsEnabled(EnableVariableName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace MxGateway.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit collection that serializes every live integration-test class. The live
|
||||
/// suites contend for genuinely shared singletons — one MXAccess COM provider,
|
||||
/// one <c>ZB</c> SQL database, and one GLAuth instance with a per-IP failure
|
||||
/// lockout — so they must not run in parallel with one another. Placing each
|
||||
/// live class in this collection disables xUnit's default cross-class
|
||||
/// parallelism for them while leaving non-live tests free to parallelize.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class LiveResourcesCollection
|
||||
{
|
||||
/// <summary>The collection name applied via <c>[Collection]</c> on live test classes.</summary>
|
||||
public const string Name = "Live external resources";
|
||||
}
|
||||
@@ -17,6 +17,7 @@ using Xunit.Abstractions;
|
||||
|
||||
namespace MxGateway.IntegrationTests;
|
||||
|
||||
[Collection(LiveResourcesCollection.Name)]
|
||||
public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
||||
{
|
||||
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15);
|
||||
@@ -40,6 +41,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
||||
string? sessionId = null;
|
||||
RecordingServerStreamWriter<MxEvent>? eventWriter = null;
|
||||
Task? streamTask = null;
|
||||
using CancellationTokenSource streamCancellation = new();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -61,7 +63,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
||||
streamTask = fixture.Service.StreamEvents(
|
||||
new StreamEventsRequest { SessionId = sessionId },
|
||||
eventWriter,
|
||||
new TestServerCallContext());
|
||||
new TestServerCallContext(streamCancellation.Token));
|
||||
|
||||
MxCommandReply registerReply = await fixture.Service.Invoke(
|
||||
CreateRegisterRequest(sessionId),
|
||||
@@ -94,7 +96,8 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
||||
MxEvent dataChange = await eventWriter
|
||||
.WaitForMessageAsync(
|
||||
candidate => candidate.Family == MxEventFamily.OnDataChange,
|
||||
IntegrationTestEnvironment.LiveMxAccessEventTimeout)
|
||||
IntegrationTestEnvironment.LiveMxAccessEventTimeout,
|
||||
streamCancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
LogEvent(dataChange);
|
||||
|
||||
@@ -560,12 +563,20 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
||||
/// </summary>
|
||||
/// <param name="predicate">Filter the awaited message must satisfy.</param>
|
||||
/// <param name="timeout">The maximum total time to wait.</param>
|
||||
/// <param name="cancellationToken">
|
||||
/// Token observed alongside the timeout so a per-test cancellation (for example the
|
||||
/// gRPC call context's token) aborts the wait promptly instead of hanging until the
|
||||
/// timeout elapses.
|
||||
/// </param>
|
||||
/// <returns>The first message that satisfies the predicate.</returns>
|
||||
public async Task<T> WaitForMessageAsync(
|
||||
Func<T, bool> predicate,
|
||||
TimeSpan timeout)
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using CancellationTokenSource timeoutCancellation = new(timeout);
|
||||
using CancellationTokenSource linkedCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellation.Token, cancellationToken);
|
||||
int scanned = 0;
|
||||
|
||||
while (true)
|
||||
@@ -586,7 +597,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
||||
|
||||
try
|
||||
{
|
||||
await messageArrived.WaitAsync(timeoutCancellation.Token).ConfigureAwait(false);
|
||||
await messageArrived.WaitAsync(linkedCancellation.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCancellation.IsCancellationRequested)
|
||||
{
|
||||
@@ -598,7 +609,9 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock server call context for testing gRPC calls.
|
||||
/// Minimal <see cref="ServerCallContext"/> stub for invoking the gRPC service
|
||||
/// in-process. It is a hand-written fake with no verification behavior — it
|
||||
/// only supplies the context values the service reads during a call.
|
||||
/// </summary>
|
||||
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
|
||||
{
|
||||
|
||||
@@ -165,19 +165,26 @@ else
|
||||
{
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="API key actions">
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => RotateApiKeyAsync(key.KeyId)">
|
||||
Rotate
|
||||
</button>
|
||||
@if (key.RevokedUtc is null)
|
||||
{
|
||||
@* Rotate clears revoked_utc, which would silently reactivate a
|
||||
deliberately revoked key. Only offer it for active keys so a
|
||||
revoked key is not un-revoked as a side effect of rotation. *@
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => RotateApiKeyAsync(key.KeyId)">
|
||||
Rotate
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => RevokeApiKeyAsync(key.KeyId)">
|
||||
Revoke
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">No actions</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -5,6 +6,14 @@ namespace MxGateway.Server.Galaxy;
|
||||
|
||||
public static class GalaxyGlobMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiled-regex cache keyed by glob pattern. <c>IsMatch</c> is called once per
|
||||
/// object per <c>DiscoverHierarchy</c>/<c>WatchDeployEvents</c> evaluation, so the
|
||||
/// same handful of glob patterns are translated repeatedly; caching avoids
|
||||
/// rebuilding and recompiling the regex on every call.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, Regex> RegexCache = new(StringComparer.Ordinal);
|
||||
|
||||
public static bool IsMatch(string value, string glob)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(glob))
|
||||
@@ -12,11 +21,15 @@ public static class GalaxyGlobMatcher
|
||||
return true;
|
||||
}
|
||||
|
||||
return Regex.IsMatch(
|
||||
value ?? string.Empty,
|
||||
BuildRegex(glob),
|
||||
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
return GetOrCreateRegex(glob).IsMatch(value ?? string.Empty);
|
||||
}
|
||||
|
||||
private static Regex GetOrCreateRegex(string glob)
|
||||
{
|
||||
return RegexCache.GetOrAdd(glob, static pattern => new Regex(
|
||||
BuildRegex(pattern),
|
||||
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
TimeSpan.FromMilliseconds(100)));
|
||||
}
|
||||
|
||||
private static string BuildRegex(string glob)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Grpc.Core;
|
||||
@@ -7,6 +9,18 @@ namespace MxGateway.Server.Galaxy;
|
||||
|
||||
public static class GalaxyHierarchyProjector
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-cache-entry memo of filtered, ordered <see cref="GalaxyObjectView"/> lists
|
||||
/// keyed by filter signature. Without it, paging through a large hierarchy
|
||||
/// re-applies every filter and re-scans the full <see cref="GalaxyHierarchyIndex.ObjectViews"/>
|
||||
/// collection on every page — O(total) per page, O(total²/pageSize) end-to-end.
|
||||
/// With it, the first page builds the filtered list and each subsequent page is an
|
||||
/// O(pageSize) slice. The table is keyed on the immutable cache-entry instance, so
|
||||
/// when the cache publishes a new entry the stale memo becomes unreachable and is
|
||||
/// reclaimed with it — no explicit invalidation needed.
|
||||
/// </summary>
|
||||
private static readonly ConditionalWeakTable<GalaxyHierarchyCacheEntry, ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>> FilteredViewCache = new();
|
||||
|
||||
public static GalaxyHierarchyQueryResult Project(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
DiscoverHierarchyRequest request,
|
||||
@@ -39,8 +53,6 @@ public static class GalaxyHierarchyProjector
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero.");
|
||||
}
|
||||
|
||||
IReadOnlyList<GalaxyObjectView> views = entry.Index.ObjectViews;
|
||||
GalaxyObjectView? root = ResolveRoot(request, views);
|
||||
int? maxDepth = request.MaxDepth;
|
||||
if (maxDepth < 0)
|
||||
{
|
||||
@@ -49,30 +61,61 @@ public static class GalaxyHierarchyProjector
|
||||
"DiscoverHierarchy max_depth must be greater than or equal to zero when provided."));
|
||||
}
|
||||
|
||||
List<GalaxyObject> page = [];
|
||||
int matchedCount = 0;
|
||||
string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs);
|
||||
IReadOnlyList<GalaxyObjectView> matchedViews = GetFilteredViews(
|
||||
entry,
|
||||
request,
|
||||
browseSubtreeGlobs,
|
||||
maxDepth,
|
||||
filterSignature);
|
||||
|
||||
bool includeAttributes = IncludeAttributes(request);
|
||||
foreach (GalaxyObjectView view in views)
|
||||
List<GalaxyObject> page = new(Math.Min(pageSize, Math.Max(0, matchedViews.Count - offset)));
|
||||
int end = (int)Math.Min((long)offset + pageSize, matchedViews.Count);
|
||||
for (int index = offset; index < end; index++)
|
||||
{
|
||||
if (!MatchesRoot(view, root, maxDepth)
|
||||
|| !MatchesBrowseSubtrees(view, browseSubtreeGlobs)
|
||||
|| !MatchesFilters(view.Object, request))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchedCount >= offset && page.Count < pageSize)
|
||||
{
|
||||
page.Add(CloneObject(view.Object, includeAttributes));
|
||||
}
|
||||
|
||||
matchedCount++;
|
||||
page.Add(CloneObject(matchedViews[index].Object, includeAttributes));
|
||||
}
|
||||
|
||||
return new GalaxyHierarchyQueryResult(
|
||||
page,
|
||||
matchedCount,
|
||||
ComputeFilterSignature(request, browseSubtreeGlobs));
|
||||
matchedViews.Count,
|
||||
filterSignature);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GalaxyObjectView> GetFilteredViews(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||
int? maxDepth,
|
||||
string filterSignature)
|
||||
{
|
||||
// ResolveRoot can throw RpcException(NotFound); run it before consulting the
|
||||
// memo so a bad root surfaces consistently regardless of cache state.
|
||||
IReadOnlyList<GalaxyObjectView> views = entry.Index.ObjectViews;
|
||||
GalaxyObjectView? root = ResolveRoot(request, views);
|
||||
|
||||
ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>> memo =
|
||||
FilteredViewCache.GetValue(entry, static _ => new ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>(StringComparer.Ordinal));
|
||||
|
||||
return memo.GetOrAdd(
|
||||
filterSignature,
|
||||
static (_, state) =>
|
||||
{
|
||||
List<GalaxyObjectView> matched = [];
|
||||
foreach (GalaxyObjectView view in state.Views)
|
||||
{
|
||||
if (MatchesRoot(view, state.Root, state.MaxDepth)
|
||||
&& MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs)
|
||||
&& MatchesFilters(view.Object, state.Request))
|
||||
{
|
||||
matched.Add(view);
|
||||
}
|
||||
}
|
||||
|
||||
return matched;
|
||||
},
|
||||
(Views: views, Root: root, MaxDepth: maxDepth, BrowseSubtreeGlobs: browseSubtreeGlobs, Request: request));
|
||||
}
|
||||
|
||||
public static GalaxyObject? FindObjectForTag(
|
||||
|
||||
@@ -115,6 +115,11 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
{
|
||||
DateTimeOffset? lastSeen = request.LastSeenDeployTime?.ToDateTimeOffset();
|
||||
|
||||
// The caller's identity (and therefore its browse-subtree constraints) is fixed
|
||||
// for the lifetime of the stream, so resolve the subtrees once rather than per
|
||||
// streamed event.
|
||||
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
|
||||
|
||||
await foreach (GalaxyDb.GalaxyDeployEventInfo info in notifier
|
||||
.SubscribeAsync(context.CancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
@@ -129,7 +134,7 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
}
|
||||
lastSeen = null;
|
||||
|
||||
await responseStream.WriteAsync(MapDeployEvent(info, ResolveBrowseSubtrees()), context.CancellationToken).ConfigureAwait(false);
|
||||
await responseStream.WriteAsync(MapDeployEvent(info, browseSubtrees), context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,13 +161,14 @@ public sealed class MxAccessGatewayService(
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// PR A.3 — surfaces the public AcknowledgeAlarm RPC. The gateway resolves the
|
||||
/// session and returns a successful reply; the actual worker-side ack call ships
|
||||
/// in <c>PR A.2</c> which adds the MxAccess alarm subscription + worker command
|
||||
/// handler. Clients calling this method today receive an OK reply with a
|
||||
/// "worker alarm path not yet wired" diagnostic — no PERMISSION_DENIED, no
|
||||
/// UNIMPLEMENTED, so the .NET / Python / Go / Java / Rust SDK call sites land
|
||||
/// on a stable surface.
|
||||
/// Surfaces the public AcknowledgeAlarm RPC. The gateway validates the request,
|
||||
/// resolves the session, and delegates to the registered
|
||||
/// <see cref="IAlarmRpcDispatcher"/>. DI binds the production
|
||||
/// <see cref="MxGateway.Server.Sessions.WorkerAlarmRpcDispatcher"/>, which routes
|
||||
/// the ack through the worker pipe IPC: an <c>alarm_full_reference</c> that parses
|
||||
/// as a canonical GUID forwards to <c>AcknowledgeAlarmCommand</c>; a
|
||||
/// <c>Provider!Group.Tag</c> reference forwards to <c>AcknowledgeAlarmByNameCommand</c>;
|
||||
/// anything else returns an <c>InvalidRequest</c> diagnostic.
|
||||
/// </remarks>
|
||||
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
|
||||
AcknowledgeAlarmRequest request,
|
||||
@@ -189,11 +190,11 @@ public sealed class MxAccessGatewayService(
|
||||
// gRPC NotFound by the caller's MapException.
|
||||
_ = ResolveSession(request.SessionId);
|
||||
|
||||
// PR A.6 — delegate to the alarm dispatcher. NotWiredAlarmRpcDispatcher
|
||||
// (default) returns OK + a worker-pending diagnostic. Production
|
||||
// WorkerAlarmRpcDispatcher (dev-rig follow-up) routes through the
|
||||
// worker IPC to AlarmClient.AlarmAckByGUID with full operator-identity
|
||||
// fidelity.
|
||||
// Delegate to the registered alarm dispatcher. DI binds the production
|
||||
// WorkerAlarmRpcDispatcher, which routes the ack over the worker IPC by
|
||||
// GUID (AcknowledgeAlarmCommand) or by Provider!Group.Tag reference
|
||||
// (AcknowledgeAlarmByNameCommand). NotWiredAlarmRpcDispatcher is only the
|
||||
// null fallback used when no dispatcher is registered.
|
||||
return await alarmRpcDispatcher.AcknowledgeAsync(request, context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
@@ -205,12 +206,12 @@ public sealed class MxAccessGatewayService(
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// PR A.3 — surfaces the public QueryActiveAlarms RPC as an empty stream until
|
||||
/// PR A.2 adds the worker-side QueryActiveAlarmsCommand that walks the
|
||||
/// MxAccess active-alarm collection. Clients can call the RPC and iterate the
|
||||
/// stream; today the stream completes immediately. Once A.2 ships, this
|
||||
/// handler will translate the request into a WorkerCommand and stream the
|
||||
/// resulting snapshots.
|
||||
/// Surfaces the public QueryActiveAlarms RPC. The gateway validates the request,
|
||||
/// resolves the session, and delegates to the registered
|
||||
/// <see cref="IAlarmRpcDispatcher"/>. DI binds the production
|
||||
/// <see cref="MxGateway.Server.Sessions.WorkerAlarmRpcDispatcher"/>, which issues a
|
||||
/// <c>QueryActiveAlarmsCommand</c> over the worker pipe IPC and streams each
|
||||
/// <c>ActiveAlarmSnapshot</c> from the worker reply.
|
||||
/// </remarks>
|
||||
public override async Task QueryActiveAlarms(
|
||||
QueryActiveAlarmsRequest request,
|
||||
@@ -226,11 +227,11 @@ public sealed class MxAccessGatewayService(
|
||||
}
|
||||
_ = ResolveSession(request.SessionId);
|
||||
|
||||
// PR A.7 — delegate to the alarm dispatcher. NotWiredAlarmRpcDispatcher
|
||||
// (default) yields an empty stream. Production WorkerAlarmRpcDispatcher
|
||||
// (dev-rig follow-up) walks the worker's IMxAccessAlarmConsumer
|
||||
// SnapshotActiveAlarms output and translates each AlarmRecord into an
|
||||
// ActiveAlarmSnapshot.
|
||||
// Delegate to the registered alarm dispatcher. DI binds the production
|
||||
// WorkerAlarmRpcDispatcher, which issues a QueryActiveAlarmsCommand over the
|
||||
// worker IPC and streams each ActiveAlarmSnapshot from the worker reply.
|
||||
// NotWiredAlarmRpcDispatcher is only the null fallback used when no
|
||||
// dispatcher is registered.
|
||||
await foreach (ActiveAlarmSnapshot snapshot in alarmRpcDispatcher
|
||||
.QueryActiveAlarmsAsync(request, context.CancellationToken)
|
||||
.WithCancellation(context.CancellationToken)
|
||||
|
||||
@@ -10,7 +10,17 @@ namespace MxGateway.Server.Security.Authentication;
|
||||
public sealed class AuthSqliteConnectionFactory(IOptions<GatewayOptions> options)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates and configures a SQLite connection to the auth database.
|
||||
/// Busy timeout applied to every auth-store connection. SQLite retries a busy
|
||||
/// database for this long before surfacing <c>SQLITE_BUSY</c>, so the concurrent
|
||||
/// <c>MarkKeyUsedAsync</c> / audit-append writers degrade gracefully under load
|
||||
/// instead of failing the request path.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unopened SQLite connection to the auth database. Prefer
|
||||
/// <see cref="OpenConnectionAsync"/>, which also applies WAL journaling and the
|
||||
/// busy timeout.
|
||||
/// </summary>
|
||||
public SqliteConnection CreateConnection()
|
||||
{
|
||||
@@ -25,9 +35,44 @@ public sealed class AuthSqliteConnectionFactory(IOptions<GatewayOptions> options
|
||||
SqliteConnectionStringBuilder builder = new()
|
||||
{
|
||||
DataSource = sqlitePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Pooling = true,
|
||||
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
|
||||
};
|
||||
|
||||
return new SqliteConnection(builder.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SQLite connection, opens it, and configures WAL journaling and a
|
||||
/// non-zero busy timeout so concurrent readers and writers degrade gracefully
|
||||
/// rather than surfacing <c>SQLITE_BUSY</c> as a hard failure.
|
||||
/// </summary>
|
||||
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
SqliteConnection connection = CreateConnection();
|
||||
try
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ConfigureConnectionAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ConfigureConnectionAsync(
|
||||
SqliteConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// WAL is a persistent, database-level setting; re-applying it per connection
|
||||
// is cheap and a no-op once set. busy_timeout is per-connection state.
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
$"PRAGMA journal_mode=WAL; PRAGMA busy_timeout={(int)BusyTimeout.TotalMilliseconds};";
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
/// <inheritdoc />
|
||||
public async Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
@@ -44,8 +43,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
@@ -70,8 +68,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
@@ -94,8 +91,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
DateTimeOffset rotatedUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
|
||||
@@ -7,8 +7,7 @@ public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectio
|
||||
/// <inheritdoc />
|
||||
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
@@ -32,8 +31,7 @@ public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectio
|
||||
return [];
|
||||
}
|
||||
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
|
||||
@@ -20,8 +20,7 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact
|
||||
/// <inheritdoc />
|
||||
public async Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
@@ -40,8 +39,7 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact
|
||||
bool requireActive,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = requireActive
|
||||
|
||||
@@ -8,8 +8,7 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteTransaction transaction =
|
||||
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Grpc;
|
||||
@@ -11,39 +8,33 @@ namespace MxGateway.Server.Sessions;
|
||||
/// <summary>
|
||||
/// Production <see cref="IAlarmRpcDispatcher"/> that routes the public
|
||||
/// <c>AcknowledgeAlarm</c> + <c>QueryActiveAlarms</c> RPCs through the
|
||||
/// worker pipe IPC. Replaces <see cref="NotWiredAlarmRpcDispatcher"/>
|
||||
/// once the worker AlarmCommandHandler is wired in.
|
||||
/// worker pipe IPC. DI binds this dispatcher; <see cref="NotWiredAlarmRpcDispatcher"/>
|
||||
/// is only the null fallback used when no dispatcher is registered.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>QueryActiveAlarms</c> is fully wired: issues a
|
||||
/// <c>QueryActiveAlarms</c> issues a
|
||||
/// <see cref="QueryActiveAlarmsCommand"/> over the pipe and yields
|
||||
/// each <see cref="ActiveAlarmSnapshot"/> from the
|
||||
/// <see cref="QueryActiveAlarmsReplyPayload"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>AcknowledgeAlarm</c> is partially wired: the public RPC's
|
||||
/// <see cref="AcknowledgeAlarmRequest.AlarmFullReference"/> is a
|
||||
/// <c>Provider!Group.Tag</c> string, but the worker's wnwrap consumer
|
||||
/// acks by GUID. When the supplied reference parses as a GUID
|
||||
/// directly, the dispatcher forwards it as-is. Otherwise it
|
||||
/// returns an <c>Unimplemented</c> diagnostic. Resolving
|
||||
/// reference→GUID requires an additional worker IPC command
|
||||
/// (e.g. <c>AlarmAckByName</c> wrapping
|
||||
/// <c>wwAlarmConsumerClass.AlarmAckByName</c>) and is tracked as
|
||||
/// a follow-up.
|
||||
/// <c>AcknowledgeAlarm</c> accepts either form of
|
||||
/// <see cref="AcknowledgeAlarmRequest.AlarmFullReference"/>: a canonical
|
||||
/// GUID forwards as an <see cref="AcknowledgeAlarmCommand"/>; a
|
||||
/// <c>Provider!Group.Tag</c> reference is parsed by
|
||||
/// <see cref="TryParseAlarmReference"/> and forwarded as an
|
||||
/// <see cref="AcknowledgeAlarmByNameCommand"/>. Any other reference
|
||||
/// returns an <c>InvalidRequest</c> diagnostic.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class WorkerAlarmRpcDispatcher : IAlarmRpcDispatcher
|
||||
public sealed class WorkerAlarmRpcDispatcher(
|
||||
ISessionRegistry sessionRegistry,
|
||||
TimeProvider? timeProvider = null) : IAlarmRpcDispatcher
|
||||
{
|
||||
private readonly ISessionRegistry sessionRegistry;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public WorkerAlarmRpcDispatcher(ISessionRegistry sessionRegistry, TimeProvider? timeProvider = null)
|
||||
{
|
||||
this.sessionRegistry = sessionRegistry ?? throw new System.ArgumentNullException(nameof(sessionRegistry));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
private readonly ISessionRegistry sessionRegistry = sessionRegistry
|
||||
?? throw new ArgumentNullException(nameof(sessionRegistry));
|
||||
private readonly TimeProvider timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
/// <summary>
|
||||
/// Parse a full alarm reference of the form <c>Provider!Group.Tag</c>
|
||||
@@ -83,7 +74,7 @@ public sealed class WorkerAlarmRpcDispatcher : IAlarmRpcDispatcher
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null) throw new System.ArgumentNullException(nameof(request));
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!sessionRegistry.TryGet(request.SessionId, out GatewaySession session))
|
||||
{
|
||||
@@ -98,7 +89,7 @@ public sealed class WorkerAlarmRpcDispatcher : IAlarmRpcDispatcher
|
||||
}
|
||||
|
||||
WorkerCommand workerCommand;
|
||||
if (System.Guid.TryParse(request.AlarmFullReference, out System.Guid guid))
|
||||
if (Guid.TryParse(request.AlarmFullReference, out Guid guid))
|
||||
{
|
||||
workerCommand = new WorkerCommand
|
||||
{
|
||||
@@ -193,7 +184,7 @@ public sealed class WorkerAlarmRpcDispatcher : IAlarmRpcDispatcher
|
||||
QueryActiveAlarmsRequest request,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null) throw new System.ArgumentNullException(nameof(request));
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!sessionRegistry.TryGet(request.SessionId, out GatewaySession session))
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using Google.Protobuf;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace MxGateway.Tests.Contracts;
|
||||
|
||||
@@ -439,4 +440,334 @@ public sealed class ProtobufContractRoundTripTests
|
||||
Assert.Equal(withoutFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray()));
|
||||
Assert.Equal(withFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an MxValue carrying a raw_value bytes payload round-trips.</summary>
|
||||
[Fact]
|
||||
public void MxValue_RoundTripsRawValueBytesPayload()
|
||||
{
|
||||
var original = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Unknown,
|
||||
VariantType = "VT_UNKNOWN",
|
||||
RawDataType = 99,
|
||||
RawDiagnostic = "uninterpreted COM variant",
|
||||
RawValue = ByteString.CopyFrom(0x01, 0x02, 0xFE, 0xFF),
|
||||
};
|
||||
|
||||
var parsed = MxValue.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(MxValue.KindOneofCase.RawValue, parsed.KindCase);
|
||||
Assert.Equal(new byte[] { 0x01, 0x02, 0xFE, 0xFF }, parsed.RawValue.ToByteArray());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an MxArray carrying a RawArray of byte blobs round-trips.</summary>
|
||||
[Fact]
|
||||
public void MxArray_RoundTripsRawArrayPayload()
|
||||
{
|
||||
var original = new MxArray
|
||||
{
|
||||
ElementDataType = MxDataType.Unknown,
|
||||
VariantType = "VT_ARRAY|VT_UNKNOWN",
|
||||
RawElementDataType = 99,
|
||||
RawDiagnostic = "uninterpreted SAFEARRAY",
|
||||
Dimensions = { 2 },
|
||||
RawValues = new RawArray
|
||||
{
|
||||
Values =
|
||||
{
|
||||
ByteString.CopyFrom(0xAA, 0xBB),
|
||||
ByteString.CopyFrom(0xCC, 0xDD),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = MxArray.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(MxArray.ValuesOneofCase.RawValues, parsed.ValuesCase);
|
||||
Assert.Equal(2, parsed.RawValues.Values.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a BulkSubscribeReply with per-item SubscribeResults round-trips.</summary>
|
||||
[Fact]
|
||||
public void BulkSubscribeReply_RoundTripsSubscribeResults()
|
||||
{
|
||||
var original = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult
|
||||
{
|
||||
ServerHandle = 10,
|
||||
TagAddress = "Provider!Tank01.Level",
|
||||
ItemHandle = 21,
|
||||
WasSuccessful = true,
|
||||
},
|
||||
new SubscribeResult
|
||||
{
|
||||
ServerHandle = 10,
|
||||
TagAddress = "Provider!Bad.Tag",
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "item not found",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = BulkSubscribeReply.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(2, parsed.Results.Count);
|
||||
Assert.True(parsed.Results[0].WasSuccessful);
|
||||
Assert.False(parsed.Results[1].WasSuccessful);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a bulk-subscribe command and its BulkSubscribeReply payload round-trip.</summary>
|
||||
[Fact]
|
||||
public void MxCommandReply_RoundTripsBulkSubscribePayload()
|
||||
{
|
||||
var original = new MxCommandReply
|
||||
{
|
||||
SessionId = "session-1",
|
||||
CorrelationId = "gateway-correlation-bulk",
|
||||
Kind = MxCommandKind.SubscribeBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
Hresult = 0,
|
||||
SubscribeBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult
|
||||
{
|
||||
ServerHandle = 5,
|
||||
TagAddress = "Provider!Tank01.Level",
|
||||
ItemHandle = 7,
|
||||
WasSuccessful = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(MxCommandReply.PayloadOneofCase.SubscribeBulk, parsed.PayloadCase);
|
||||
Assert.Single(parsed.SubscribeBulk.Results);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a WorkerEnvelope carrying a WorkerFault body round-trips.</summary>
|
||||
[Fact]
|
||||
public void WorkerEnvelope_RoundTripsWorkerFaultBody()
|
||||
{
|
||||
var original = new WorkerEnvelope
|
||||
{
|
||||
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
SessionId = "session-1",
|
||||
Sequence = 11,
|
||||
CorrelationId = "gateway-correlation-fault",
|
||||
WorkerFault = new WorkerFault
|
||||
{
|
||||
Category = WorkerFaultCategory.MxaccessCommandFailed,
|
||||
CommandMethod = "Register",
|
||||
Hresult = unchecked((int)0x80004005),
|
||||
ExceptionType = "System.Runtime.InteropServices.COMException",
|
||||
DiagnosticMessage = "MXAccess COM call failed.",
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.ProtocolViolation },
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerFault, parsed.BodyCase);
|
||||
Assert.True(parsed.WorkerFault.HasHresult);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a WorkerEnvelope carrying a WorkerHeartbeat body round-trips.</summary>
|
||||
[Fact]
|
||||
public void WorkerEnvelope_RoundTripsWorkerHeartbeatBody()
|
||||
{
|
||||
var activity = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 9, 0, 0, DateTimeKind.Utc));
|
||||
var original = new WorkerEnvelope
|
||||
{
|
||||
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
SessionId = "session-1",
|
||||
Sequence = 12,
|
||||
CorrelationId = "gateway-correlation-heartbeat",
|
||||
WorkerHeartbeat = new WorkerHeartbeat
|
||||
{
|
||||
WorkerProcessId = 4242,
|
||||
State = WorkerState.Ready,
|
||||
LastStaActivityTimestamp = activity,
|
||||
PendingCommandCount = 3,
|
||||
OutboundEventQueueDepth = 7,
|
||||
LastEventSequence = 1234,
|
||||
CurrentCommandCorrelationId = "in-flight-1",
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerHeartbeat, parsed.BodyCase);
|
||||
Assert.Equal(WorkerState.Ready, parsed.WorkerHeartbeat.State);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Galaxy Repository service descriptor exposes its browse RPCs.</summary>
|
||||
[Fact]
|
||||
public void GalaxyRepositoryDescriptor_ContainsBrowseServiceMethods()
|
||||
{
|
||||
var service = Assert.Single(
|
||||
GalaxyRepositoryReflection.Descriptor.Services,
|
||||
descriptor => descriptor.Name == "GalaxyRepository");
|
||||
|
||||
Assert.Contains(service.Methods, method => method.Name == "TestConnection");
|
||||
Assert.Contains(service.Methods, method => method.Name == "GetLastDeployTime");
|
||||
Assert.Contains(service.Methods, method => method.Name == "DiscoverHierarchy");
|
||||
Assert.Contains(service.Methods, method => method.Name == "WatchDeployEvents");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a DiscoverHierarchyRequest round-trips through every
|
||||
/// <c>root</c> oneof arm and its proto wrapper-typed <c>max_depth</c> field.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void DiscoverHierarchyRequest_RoundTripsRootOneofAndWrapperFields(int rootArm)
|
||||
{
|
||||
var original = new DiscoverHierarchyRequest
|
||||
{
|
||||
PageSize = 100,
|
||||
PageToken = "page-2",
|
||||
MaxDepth = 5,
|
||||
CategoryIds = { 3, 9 },
|
||||
TemplateChainContains = { "Analog", "Pump" },
|
||||
TagNameGlob = "Tank*",
|
||||
IncludeAttributes = true,
|
||||
AlarmBearingOnly = true,
|
||||
HistorizedOnly = false,
|
||||
};
|
||||
switch (rootArm)
|
||||
{
|
||||
case 0:
|
||||
original.RootGobjectId = 4711;
|
||||
break;
|
||||
case 1:
|
||||
original.RootTagName = "Tank01";
|
||||
break;
|
||||
default:
|
||||
original.RootContainedPath = "Area1.Tank01";
|
||||
break;
|
||||
}
|
||||
|
||||
var parsed = DiscoverHierarchyRequest.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(original.RootCase, parsed.RootCase);
|
||||
Assert.NotEqual(DiscoverHierarchyRequest.RootOneofCase.None, parsed.RootCase);
|
||||
Assert.NotNull(parsed.MaxDepth);
|
||||
Assert.Equal(5, parsed.MaxDepth!.Value);
|
||||
Assert.True(parsed.HasIncludeAttributes);
|
||||
Assert.True(parsed.IncludeAttributes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a DiscoverHierarchyReply round-trips with nested
|
||||
/// GalaxyObject and GalaxyAttribute graphs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DiscoverHierarchyReply_RoundTripsObjectAndAttributeGraph()
|
||||
{
|
||||
var original = new DiscoverHierarchyReply
|
||||
{
|
||||
NextPageToken = "page-3",
|
||||
TotalObjectCount = 2,
|
||||
Objects =
|
||||
{
|
||||
new GalaxyObject
|
||||
{
|
||||
GobjectId = 4711,
|
||||
TagName = "Tank01",
|
||||
ContainedName = "Tank01",
|
||||
BrowseName = "Tank 01",
|
||||
ParentGobjectId = 12,
|
||||
IsArea = false,
|
||||
CategoryId = 3,
|
||||
HostedByGobjectId = 8,
|
||||
TemplateChain = { "$AnalogDevice", "$Tank" },
|
||||
Attributes =
|
||||
{
|
||||
new GalaxyAttribute
|
||||
{
|
||||
AttributeName = "Level",
|
||||
FullTagReference = "Galaxy!Tank01.Level",
|
||||
MxDataType = 3,
|
||||
DataTypeName = "Float",
|
||||
IsArray = false,
|
||||
ArrayDimension = 0,
|
||||
ArrayDimensionPresent = false,
|
||||
MxAttributeCategory = 1,
|
||||
SecurityClassification = 0,
|
||||
IsHistorized = true,
|
||||
IsAlarm = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
new GalaxyObject
|
||||
{
|
||||
GobjectId = 12,
|
||||
TagName = "Area1",
|
||||
IsArea = true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = DiscoverHierarchyReply.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(2, parsed.Objects.Count);
|
||||
Assert.Single(parsed.Objects[0].Attributes);
|
||||
Assert.True(parsed.Objects[0].Attributes[0].IsAlarm);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a DeployEvent round-trips with its timestamp and counters.</summary>
|
||||
[Fact]
|
||||
public void DeployEvent_RoundTripsTimestampAndCounters()
|
||||
{
|
||||
var observed = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 30, 0, DateTimeKind.Utc));
|
||||
var deploy = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 0, 0, DateTimeKind.Utc));
|
||||
var original = new DeployEvent
|
||||
{
|
||||
Sequence = 17,
|
||||
ObservedAt = observed,
|
||||
TimeOfLastDeploy = deploy,
|
||||
TimeOfLastDeployPresent = true,
|
||||
ObjectCount = 240,
|
||||
AttributeCount = 3600,
|
||||
};
|
||||
|
||||
var parsed = DeployEvent.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.True(parsed.TimeOfLastDeployPresent);
|
||||
Assert.Equal(deploy, parsed.TimeOfLastDeploy);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetLastDeployTimeReply and TestConnectionReply round-trip.</summary>
|
||||
[Fact]
|
||||
public void GalaxyConnectionReplies_RoundTrip()
|
||||
{
|
||||
var deploy = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 0, 0, DateTimeKind.Utc));
|
||||
var lastDeploy = new GetLastDeployTimeReply
|
||||
{
|
||||
Present = true,
|
||||
TimeOfLastDeploy = deploy,
|
||||
};
|
||||
var testConnection = new TestConnectionReply { Ok = true };
|
||||
|
||||
Assert.Equal(lastDeploy, GetLastDeployTimeReply.Parser.ParseFrom(lastDeploy.ToByteArray()));
|
||||
Assert.Equal(testConnection, TestConnectionReply.Parser.ParseFrom(testConnection.ToByteArray()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using MxGateway.Server.Dashboard;
|
||||
using MxGateway.Server.Galaxy;
|
||||
using MxGateway.Server.Grpc;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Tests.Galaxy;
|
||||
|
||||
@@ -88,6 +89,27 @@ public sealed class GalaxyFilterInputSafetyTests
|
||||
Assert.True(GalaxyGlobMatcher.IsMatch("Pump_001", "Pump_00?"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for finding Server-008: <see cref="GalaxyGlobMatcher"/> caches
|
||||
/// the compiled regex per glob pattern. Repeated calls with the same pattern, and
|
||||
/// interleaved calls with different patterns, must keep returning the correct
|
||||
/// literal-vs-wildcard result rather than a stale cached match.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GlobMatcher_RepeatedAndInterleavedPatterns_StayCorrect()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
Assert.True(GalaxyGlobMatcher.IsMatch("Pump_001", "Pump_*"));
|
||||
Assert.False(GalaxyGlobMatcher.IsMatch("Valve_001", "Pump_*"));
|
||||
Assert.True(GalaxyGlobMatcher.IsMatch("Valve_001", "Valve_00?"));
|
||||
Assert.False(GalaxyGlobMatcher.IsMatch("Pump_001", "Valve_00?"));
|
||||
// A glob equal to a SQL metacharacter still matches only its literal.
|
||||
Assert.True(GalaxyGlobMatcher.IsMatch("%", "%"));
|
||||
Assert.False(GalaxyGlobMatcher.IsMatch("anything", "%"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a pathological glob does not cause catastrophic regex backtracking —
|
||||
/// <see cref="GalaxyGlobMatcher"/> escapes every literal character and applies a
|
||||
@@ -281,51 +303,4 @@ public sealed class GalaxyFilterInputSafetyTests
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
|
||||
{
|
||||
private readonly Metadata requestHeaders = [];
|
||||
private readonly Metadata responseTrailers = [];
|
||||
private readonly Dictionary<object, object> userState = [];
|
||||
private Status status;
|
||||
private WriteOptions? writeOptions;
|
||||
|
||||
protected override string MethodCore => "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy";
|
||||
|
||||
protected override string HostCore => "localhost";
|
||||
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||
|
||||
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||
|
||||
protected override Metadata RequestHeadersCore => requestHeaders;
|
||||
|
||||
protected override CancellationToken CancellationTokenCore => cancellationToken;
|
||||
|
||||
protected override Metadata ResponseTrailersCore => responseTrailers;
|
||||
|
||||
protected override Status StatusCore
|
||||
{
|
||||
get => status;
|
||||
set => status = value;
|
||||
}
|
||||
|
||||
protected override WriteOptions? WriteOptionsCore
|
||||
{
|
||||
get => writeOptions;
|
||||
set => writeOptions = value;
|
||||
}
|
||||
|
||||
protected override AuthContext AuthContextCore { get; } = new(
|
||||
string.Empty,
|
||||
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||
|
||||
protected override IDictionary<object, object> UserStateCore => userState;
|
||||
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
||||
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using MxGateway.Server.Dashboard;
|
||||
using MxGateway.Server.Galaxy;
|
||||
|
||||
namespace MxGateway.Tests.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Direct coverage for <see cref="GalaxyHierarchyProjector"/> paging.
|
||||
/// <para>
|
||||
/// Regression guard for finding Server-007: the projector memoizes the filtered,
|
||||
/// ordered view list per <c>(cache entry, filter signature)</c> so paging is an
|
||||
/// O(pageSize) slice rather than an O(total) re-scan per page. These tests confirm
|
||||
/// the memo does not change paging results, does not bleed between distinct filter
|
||||
/// signatures, and is scoped to a single cache-entry instance.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyProjectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Project_PagedAcrossEntireHierarchy_ReturnsEveryObjectExactlyOnce()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects(25));
|
||||
|
||||
List<string> collected = [];
|
||||
int totalReported = -1;
|
||||
for (int offset = 0; offset < 25; offset += 4)
|
||||
{
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest(),
|
||||
browseSubtreeGlobs: null,
|
||||
offset,
|
||||
pageSize: 4);
|
||||
|
||||
totalReported = result.TotalObjectCount;
|
||||
collected.AddRange(result.Objects.Select(obj => obj.TagName));
|
||||
}
|
||||
|
||||
Assert.Equal(25, totalReported);
|
||||
Assert.Equal(25, collected.Count);
|
||||
Assert.Equal(collected.Count, collected.Distinct(StringComparer.Ordinal).Count());
|
||||
Assert.Equal("Object_001", collected[0]);
|
||||
Assert.Equal("Object_025", collected[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_DistinctFiltersOnSameEntry_DoNotShareMemoizedViewList()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects(10));
|
||||
|
||||
GalaxyHierarchyQueryResult globbed = GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest { TagNameGlob = "Object_00?" });
|
||||
GalaxyHierarchyQueryResult unfiltered = GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest());
|
||||
|
||||
// Distinct filter signatures must each get their own filtered list.
|
||||
Assert.Equal(9, globbed.TotalObjectCount);
|
||||
Assert.Equal(10, unfiltered.TotalObjectCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_SameFilterRepeated_ReturnsIdenticalTotals()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry(CreateObjects(12));
|
||||
|
||||
GalaxyHierarchyQueryResult first = GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest(),
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 5);
|
||||
GalaxyHierarchyQueryResult second = GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest(),
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 5,
|
||||
pageSize: 5);
|
||||
|
||||
Assert.Equal(first.TotalObjectCount, second.TotalObjectCount);
|
||||
Assert.Equal(first.FilterSignature, second.FilterSignature);
|
||||
Assert.Equal(5, first.Objects.Count);
|
||||
Assert.Equal(5, second.Objects.Count);
|
||||
Assert.NotEqual(first.Objects[0].TagName, second.Objects[0].TagName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_DistinctCacheEntries_ProjectAgainstTheirOwnData()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry small = CreateEntry(CreateObjects(3));
|
||||
GalaxyHierarchyCacheEntry large = CreateEntry(CreateObjects(40));
|
||||
|
||||
GalaxyHierarchyQueryResult smallResult = GalaxyHierarchyProjector.Project(
|
||||
small,
|
||||
new DiscoverHierarchyRequest());
|
||||
GalaxyHierarchyQueryResult largeResult = GalaxyHierarchyProjector.Project(
|
||||
large,
|
||||
new DiscoverHierarchyRequest());
|
||||
|
||||
// Each entry instance keys its own memo; the second projection must not reuse the
|
||||
// first entry's filtered view list.
|
||||
Assert.Equal(3, smallResult.TotalObjectCount);
|
||||
Assert.Equal(40, largeResult.TotalObjectCount);
|
||||
}
|
||||
|
||||
private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList<GalaxyObject> objects)
|
||||
{
|
||||
return GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 1,
|
||||
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||
Objects = objects,
|
||||
Index = GalaxyHierarchyIndex.Build(objects),
|
||||
DashboardSummary = DashboardGalaxySummary.Unknown with
|
||||
{
|
||||
Status = DashboardGalaxyStatus.Healthy,
|
||||
ObjectCount = objects.Count,
|
||||
},
|
||||
ObjectCount = objects.Count,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GalaxyObject> CreateObjects(int count)
|
||||
{
|
||||
return Enumerable.Range(1, count)
|
||||
.Select(index => new GalaxyObject
|
||||
{
|
||||
GobjectId = index,
|
||||
TagName = $"Object_{index:000}",
|
||||
BrowseName = $"Object_{index:000}",
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,23 @@ public sealed class GalaxyHierarchyRefreshServiceTests
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Wait until the first RefreshAsync has actually been attempted (and
|
||||
// thrown) before cancelling, so cancellation cannot race ahead of the
|
||||
// first-load path under test — this is what made the test flaky under
|
||||
// parallel load.
|
||||
await cache.FirstRefreshAttempted.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
await cts.CancelAsync();
|
||||
|
||||
// The background loop must have stopped cleanly: ExecuteTask completes
|
||||
// (RanToCompletion or Canceled) rather than faulting on the first refresh.
|
||||
// The background loop must have stopped cleanly: ExecuteTask reaches a
|
||||
// terminal state that is not Faulted (RanToCompletion or Canceled)
|
||||
// rather than faulting on the first refresh. WhenAny is used so a
|
||||
// Canceled task does not rethrow before the IsFaulted assertion.
|
||||
Task? executeTask = service.ExecuteTask;
|
||||
Assert.NotNull(executeTask);
|
||||
await executeTask;
|
||||
Task completed = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||
Assert.Same(executeTask, completed);
|
||||
Assert.False(executeTask.IsFaulted);
|
||||
Assert.Equal(1, cache.RefreshCallCount);
|
||||
|
||||
@@ -49,13 +59,20 @@ public sealed class GalaxyHierarchyRefreshServiceTests
|
||||
|
||||
private sealed class ThrowingCache(Exception toThrow) : IGalaxyHierarchyCache
|
||||
{
|
||||
private readonly TaskCompletionSource firstRefreshAttempted =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public int RefreshCallCount { get; private set; }
|
||||
|
||||
/// <summary>Completes once <see cref="RefreshAsync"/> has been invoked at least once.</summary>
|
||||
public Task FirstRefreshAttempted => firstRefreshAttempted.Task;
|
||||
|
||||
public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty;
|
||||
|
||||
public Task RefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RefreshCallCount++;
|
||||
firstRefreshAttempted.TrySetResult();
|
||||
throw toThrow;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,36 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the anonymous-localhost bypass is denied when <c>AllowAnonymousLocalhost</c>
|
||||
/// is off, even on a loopback connection — the misconfiguration must not expose the dashboard.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AnonymousLocalhostDisallowed_DoesNotSucceed()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Loopback,
|
||||
allowAnonymousLocalhost: false);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the anonymous-localhost bypass stays scoped to loopback: an anonymous
|
||||
/// request from a non-loopback address is denied even when <c>AllowAnonymousLocalhost</c> is on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AnonymousLocalhostAllowedFromRemoteAddress_DoesNotSucceed()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: true);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticated users without admin scope fail authorization.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed()
|
||||
|
||||
@@ -147,6 +147,8 @@ public sealed class GatewayApplicationTests
|
||||
string value,
|
||||
string expectedFailure)
|
||||
{
|
||||
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
|
||||
// WebApplication-building test must avoid a fixed port to prevent a bind collision.
|
||||
await using WebApplication app = GatewayApplication.Build(
|
||||
[$"--{key}={value}", "--urls=http://127.0.0.1:0"]);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Contracts;
|
||||
@@ -13,6 +12,7 @@ using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
using MxGateway.Tests.Gateway.Workers.Fakes;
|
||||
using MxGateway.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Tests.Gateway;
|
||||
|
||||
@@ -405,159 +405,4 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
|
||||
{
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly TaskCompletionSource<T> _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly List<T> _messages = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the recorded messages written to this stream.
|
||||
/// </summary>
|
||||
public IReadOnlyList<T> Messages
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _messages.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets options for writing messages to the stream.
|
||||
/// </summary>
|
||||
public WriteOptions? WriteOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Writes a message to the stream asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to write.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
public Task WriteAsync(T message)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_messages.Add(message);
|
||||
}
|
||||
|
||||
_firstMessage.TrySetResult(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the first message to be written within the specified timeout.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Maximum time to wait for the first message.</param>
|
||||
/// <returns>The first message written to this stream.</returns>
|
||||
public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout)
|
||||
{
|
||||
return await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
|
||||
{
|
||||
private readonly Metadata _requestHeaders = [];
|
||||
private readonly Metadata _responseTrailers = [];
|
||||
private readonly Dictionary<object, object> _userState = [];
|
||||
private Status _status;
|
||||
private WriteOptions? _writeOptions;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string HostCore => "localhost";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Metadata RequestHeadersCore => _requestHeaders;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override CancellationToken CancellationTokenCore => cancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Metadata ResponseTrailersCore => _responseTrailers;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Status StatusCore
|
||||
{
|
||||
get => _status;
|
||||
set => _status = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override WriteOptions? WriteOptionsCore
|
||||
{
|
||||
get => _writeOptions;
|
||||
set => _writeOptions = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override AuthContext AuthContextCore { get; } = new(
|
||||
string.Empty,
|
||||
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IDictionary<object, object> UserStateCore => _userState;
|
||||
|
||||
/// <summary>
|
||||
/// Writes response headers asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="responseHeaders">Headers to write.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
/// <inheritdoc />
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a context propagation token with the specified options.
|
||||
/// </summary>
|
||||
/// <param name="options">Propagation options.</param>
|
||||
/// <returns>Propagation token.</returns>
|
||||
/// <inheritdoc />
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(
|
||||
ContextPropagationOptions? options)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
||||
{
|
||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task RecordDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
string target,
|
||||
ConstraintFailure failure,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using MxGateway.Server.Dashboard;
|
||||
using MxGateway.Server.Galaxy;
|
||||
using MxGateway.Server.Grpc;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Tests.Gateway.Grpc;
|
||||
|
||||
@@ -321,51 +322,4 @@ public sealed class GalaxyRepositoryGrpcServiceTests
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
|
||||
{
|
||||
private readonly Metadata requestHeaders = [];
|
||||
private readonly Metadata responseTrailers = [];
|
||||
private readonly Dictionary<object, object> userState = [];
|
||||
private Status status;
|
||||
private WriteOptions? writeOptions;
|
||||
|
||||
protected override string MethodCore => "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy";
|
||||
|
||||
protected override string HostCore => "localhost";
|
||||
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||
|
||||
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||
|
||||
protected override Metadata RequestHeadersCore => requestHeaders;
|
||||
|
||||
protected override CancellationToken CancellationTokenCore => cancellationToken;
|
||||
|
||||
protected override Metadata ResponseTrailersCore => responseTrailers;
|
||||
|
||||
protected override Status StatusCore
|
||||
{
|
||||
get => status;
|
||||
set => status = value;
|
||||
}
|
||||
|
||||
protected override WriteOptions? WriteOptionsCore
|
||||
{
|
||||
get => writeOptions;
|
||||
set => writeOptions = value;
|
||||
}
|
||||
|
||||
protected override AuthContext AuthContextCore { get; } = new(
|
||||
string.Empty,
|
||||
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||
|
||||
protected override IDictionary<object, object> UserStateCore => userState;
|
||||
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
||||
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
using MxGateway.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Tests.Gateway.Grpc;
|
||||
|
||||
@@ -132,7 +133,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
SessionId = "session-missing",
|
||||
AlarmFilterPrefix = "Tank01.",
|
||||
},
|
||||
new RecordingStreamWriter<ActiveAlarmSnapshot>(),
|
||||
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
|
||||
@@ -225,7 +226,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 1));
|
||||
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2));
|
||||
MxAccessGatewayService service = CreateService(sessionManager);
|
||||
TestServerStreamWriter<MxEvent> writer = new();
|
||||
RecordingServerStreamWriter<MxEvent> writer = new();
|
||||
|
||||
await service.StreamEvents(
|
||||
new StreamEventsRequest
|
||||
@@ -276,7 +277,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
FakeSessionManager sessionManager = new();
|
||||
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2));
|
||||
MxAccessGatewayService service = CreateService(sessionManager, metrics: metrics);
|
||||
TestServerStreamWriter<MxEvent> writer = new();
|
||||
RecordingServerStreamWriter<MxEvent> writer = new();
|
||||
|
||||
await service.StreamEvents(
|
||||
new StreamEventsRequest { SessionId = "session-1" },
|
||||
@@ -375,7 +376,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await service.QueryActiveAlarms(
|
||||
new QueryActiveAlarmsRequest(),
|
||||
new RecordingStreamWriter<ActiveAlarmSnapshot>(),
|
||||
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
@@ -386,7 +387,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
public async Task QueryActiveAlarms_WithValidRequest_StreamsZeroSnapshots()
|
||||
{
|
||||
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
||||
RecordingStreamWriter<ActiveAlarmSnapshot> sink = new();
|
||||
RecordingServerStreamWriter<ActiveAlarmSnapshot> sink = new();
|
||||
|
||||
await service.QueryActiveAlarms(
|
||||
new QueryActiveAlarmsRequest
|
||||
@@ -397,7 +398,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
sink,
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Empty(sink.Items);
|
||||
Assert.Empty(sink.Messages);
|
||||
}
|
||||
|
||||
/// <summary>Verifies OpenSession advertises the alarm RPC capability strings.</summary>
|
||||
@@ -664,35 +665,6 @@ public sealed class MxAccessGatewayServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
||||
{
|
||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task RecordDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
string target,
|
||||
ConstraintFailure failure,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeWorkerClient(int processId) : IWorkerClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -750,97 +722,4 @@ public sealed class MxAccessGatewayServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestServerStreamWriter<T> : IServerStreamWriter<T>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public List<T> Messages { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public WriteOptions? WriteOptions { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(T message)
|
||||
{
|
||||
Messages.Add(message);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingStreamWriter<T> : IServerStreamWriter<T>
|
||||
{
|
||||
public List<T> Items { get; } = new();
|
||||
public WriteOptions? WriteOptions { get; set; }
|
||||
|
||||
public Task WriteAsync(T message)
|
||||
{
|
||||
Items.Add(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
|
||||
{
|
||||
private readonly Metadata requestHeaders = [];
|
||||
private readonly Metadata responseTrailers = [];
|
||||
private readonly Dictionary<object, object> userState = [];
|
||||
private Status status;
|
||||
private WriteOptions? writeOptions;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string HostCore => "localhost";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Metadata RequestHeadersCore => requestHeaders;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override CancellationToken CancellationTokenCore => cancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Metadata ResponseTrailersCore => responseTrailers;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Status StatusCore
|
||||
{
|
||||
get => status;
|
||||
set => status = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override WriteOptions? WriteOptionsCore
|
||||
{
|
||||
get => writeOptions;
|
||||
set => writeOptions = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override AuthContext AuthContextCore { get; } = new(
|
||||
string.Empty,
|
||||
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IDictionary<object, object> UserStateCore => userState;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(
|
||||
ContextPropagationOptions? options)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Sessions;
|
||||
|
||||
@@ -15,7 +13,7 @@ namespace MxGateway.Tests.Gateway.Sessions;
|
||||
public sealed class NotWiredAlarmRpcDispatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_returns_ok_with_worker_pending_diagnostic()
|
||||
public async Task AcknowledgeAsync_WhenNotWired_ReturnsOkWithWorkerPendingDiagnostic()
|
||||
{
|
||||
IAlarmRpcDispatcher dispatcher = new NotWiredAlarmRpcDispatcher();
|
||||
|
||||
@@ -33,11 +31,11 @@ public sealed class NotWiredAlarmRpcDispatcherTests
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Equal("session-1", reply.SessionId);
|
||||
Assert.Equal("corr-1", reply.CorrelationId);
|
||||
Assert.Contains("worker", reply.DiagnosticMessage, System.StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("worker", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_yields_no_snapshots()
|
||||
public async Task QueryActiveAlarmsAsync_WhenNotWired_YieldsNoSnapshots()
|
||||
{
|
||||
IAlarmRpcDispatcher dispatcher = new NotWiredAlarmRpcDispatcher();
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class SessionManagerTests
|
||||
Assert.Equal(1, metrics.GetSnapshot().SessionsOpened);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opening a session generates a correlation ID from the client name and session ID.</summary>
|
||||
/// <summary>Verifies that opening a session sets the initial lease expiry from the configured default lease.</summary>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_SetsInitialDefaultLease()
|
||||
{
|
||||
@@ -96,7 +96,7 @@ public sealed class SessionManagerTests
|
||||
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that bulk subscribe forwards the command and returns subscription results.</summary>
|
||||
/// <summary>Verifies that invoking a command on a ready session refreshes its lease expiry.</summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenSessionReady_RefreshesLease()
|
||||
{
|
||||
@@ -362,7 +362,7 @@ public sealed class SessionManagerTests
|
||||
Assert.Equal(0, activeClient.ShutdownCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
|
||||
/// <summary>Verifies that an expired-lease sweep leaves a session with an active event subscriber open.</summary>
|
||||
[Fact]
|
||||
public async Task CloseExpiredLeasesAsync_DoesNotCloseActiveEventSubscriber()
|
||||
{
|
||||
|
||||
+102
-17
@@ -10,15 +10,29 @@ using MxGateway.Tests.Gateway.Workers.Fakes;
|
||||
|
||||
namespace MxGateway.Tests.Gateway.Sessions;
|
||||
|
||||
public sealed class SessionWorkerClientFactoryFakeWorkerTests
|
||||
public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
|
||||
{
|
||||
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly List<IWorkerTaskLauncher> _launchers = [];
|
||||
|
||||
/// <summary>
|
||||
/// Awaits every scripted worker task so an unhandled exception fails the owning test
|
||||
/// instead of surfacing later as an unobserved <see cref="TaskScheduler.UnobservedTaskException"/>.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (IWorkerTaskLauncher launcher in _launchers)
|
||||
{
|
||||
await launcher.ObserveWorkerTaskAsync(TestTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the factory creates a ready worker client with a scripted fake worker process.</summary>
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithScriptedFakeWorker_ReturnsReadyClient()
|
||||
{
|
||||
ScriptedFakeWorkerProcessLauncher launcher = new();
|
||||
ScriptedFakeWorkerProcessLauncher launcher = Track(new ScriptedFakeWorkerProcessLauncher());
|
||||
using GatewayMetrics metrics = new();
|
||||
SessionWorkerClientFactory factory = new(
|
||||
launcher,
|
||||
@@ -51,7 +65,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
|
||||
[Fact]
|
||||
public async Task CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException()
|
||||
{
|
||||
FailingStartupWorkerProcessLauncher launcher = new();
|
||||
FailingStartupWorkerProcessLauncher launcher = Track(new FailingStartupWorkerProcessLauncher());
|
||||
using GatewayMetrics metrics = new();
|
||||
SessionWorkerClientFactory factory = new(
|
||||
launcher,
|
||||
@@ -71,7 +85,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
|
||||
[Fact]
|
||||
public async Task CreateAsync_WhenFakeWorkerNeverSendsReady_TimesOutAndKillsWorker()
|
||||
{
|
||||
NeverReadyWorkerProcessLauncher launcher = new();
|
||||
NeverReadyWorkerProcessLauncher launcher = Track(new NeverReadyWorkerProcessLauncher());
|
||||
using GatewayMetrics metrics = new();
|
||||
SessionWorkerClientFactory factory = new(
|
||||
launcher,
|
||||
@@ -134,8 +148,50 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
|
||||
};
|
||||
}
|
||||
|
||||
private T Track<T>(T launcher)
|
||||
where T : IWorkerTaskLauncher
|
||||
{
|
||||
_launchers.Add(launcher);
|
||||
|
||||
return launcher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fake worker launcher that runs a scripted worker on a background task and exposes
|
||||
/// that task so the owning test observes it rather than leaking an unobserved fault.
|
||||
/// </summary>
|
||||
private interface IWorkerTaskLauncher : IWorkerProcessLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Awaits the scripted worker task within the timeout, swallowing only the pipe
|
||||
/// teardown faults expected when the worker client kills or disposes the worker.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Maximum time to wait for the worker task.</param>
|
||||
Task ObserveWorkerTaskAsync(TimeSpan timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Awaits a scripted worker task, treating cancellation and pipe-disconnect I/O faults as
|
||||
/// the expected outcome of the worker client tearing the worker down, and rethrowing anything else.
|
||||
/// </summary>
|
||||
private static async Task ObserveWorkerTaskAsync(Task workerTask, TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
await workerTask.WaitAsync(timeout).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected: the worker client cancelled the scripted worker during teardown.
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Expected: the gateway pipe was closed when the worker client disposed.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fake worker launcher that connects a scripted fake worker harness.</summary>
|
||||
private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerProcessLauncher
|
||||
private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerTaskLauncher
|
||||
{
|
||||
/// <summary>The fake process ID used by the scripted launcher.</summary>
|
||||
public const int ProcessId = 2468;
|
||||
@@ -144,16 +200,23 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
|
||||
/// <summary>Gets the connected fake worker harness.</summary>
|
||||
public FakeWorkerHarness? Harness { get; private set; }
|
||||
|
||||
/// <summary>Gets the scripted worker task.</summary>
|
||||
public Task WorkerTask { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerProcessHandle> LaunchAsync(
|
||||
WorkerProcessLaunchRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = RunWorkerAsync(request, cancellationToken);
|
||||
WorkerTask = RunWorkerAsync(request, cancellationToken);
|
||||
|
||||
return Task.FromResult(CreateHandle(_process));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ObserveWorkerTaskAsync(TimeSpan timeout) =>
|
||||
SessionWorkerClientFactoryFakeWorkerTests.ObserveWorkerTaskAsync(WorkerTask, timeout);
|
||||
|
||||
private async Task RunWorkerAsync(
|
||||
WorkerProcessLaunchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -169,21 +232,28 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
|
||||
}
|
||||
|
||||
/// <summary>Fake worker launcher that fails during startup with protocol version mismatch.</summary>
|
||||
private sealed class FailingStartupWorkerProcessLauncher : IWorkerProcessLauncher
|
||||
private sealed class FailingStartupWorkerProcessLauncher : IWorkerTaskLauncher
|
||||
{
|
||||
/// <summary>Gets the fake worker process.</summary>
|
||||
public FakeWorkerProcess Process { get; } = new(processId: 3579);
|
||||
|
||||
/// <summary>Gets the scripted worker task.</summary>
|
||||
public Task WorkerTask { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerProcessHandle> LaunchAsync(
|
||||
WorkerProcessLaunchRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = RunWorkerAsync(request, cancellationToken);
|
||||
WorkerTask = RunWorkerAsync(request, cancellationToken);
|
||||
|
||||
return Task.FromResult(CreateHandle(Process));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ObserveWorkerTaskAsync(TimeSpan timeout) =>
|
||||
SessionWorkerClientFactoryFakeWorkerTests.ObserveWorkerTaskAsync(WorkerTask, timeout);
|
||||
|
||||
private async Task RunWorkerAsync(
|
||||
WorkerProcessLaunchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -203,37 +273,52 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests
|
||||
}
|
||||
|
||||
/// <summary>Fake worker launcher that never completes startup, simulating a hung worker.</summary>
|
||||
private sealed class NeverReadyWorkerProcessLauncher : IWorkerProcessLauncher
|
||||
private sealed class NeverReadyWorkerProcessLauncher : IWorkerTaskLauncher
|
||||
{
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
|
||||
/// <summary>Gets the fake worker process.</summary>
|
||||
public FakeWorkerProcess Process { get; } = new(processId: 4680);
|
||||
|
||||
/// <summary>Gets the scripted worker task.</summary>
|
||||
public Task WorkerTask { get; private set; } = Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerProcessHandle> LaunchAsync(
|
||||
WorkerProcessLaunchRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = RunWorkerAsync(request, cancellationToken);
|
||||
WorkerTask = RunWorkerAsync(request);
|
||||
|
||||
return Task.FromResult(CreateHandle(Process));
|
||||
}
|
||||
|
||||
private async Task RunWorkerAsync(
|
||||
WorkerProcessLaunchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
/// <inheritdoc />
|
||||
public async Task ObserveWorkerTaskAsync(TimeSpan timeout)
|
||||
{
|
||||
// The scripted worker parks on an infinite delay; cancel it so disposal observes
|
||||
// the task instead of leaking it as an unobserved fault.
|
||||
await _stop.CancelAsync().ConfigureAwait(false);
|
||||
await SessionWorkerClientFactoryFakeWorkerTests
|
||||
.ObserveWorkerTaskAsync(WorkerTask, timeout)
|
||||
.ConfigureAwait(false);
|
||||
_stop.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunWorkerAsync(WorkerProcessLaunchRequest request)
|
||||
{
|
||||
await using FakeWorkerHarness harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync(
|
||||
request.SessionId,
|
||||
request.Nonce,
|
||||
request.PipeName,
|
||||
request.ProtocolVersion,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_ = await harness.ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken: _stop.Token).ConfigureAwait(false);
|
||||
_ = await harness.ReadGatewayEnvelopeAsync(_stop.Token).ConfigureAwait(false);
|
||||
await harness.SendWorkerHelloAsync(
|
||||
workerProcessId: Process.Id,
|
||||
workerProtocolVersion: request.ProtocolVersion,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken: _stop.Token).ConfigureAwait(false);
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, _stop.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
@@ -19,10 +14,10 @@ namespace MxGateway.Tests.Gateway.Sessions;
|
||||
public sealed class WorkerAlarmRpcDispatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_returns_session_not_found_when_session_missing()
|
||||
public async Task AcknowledgeAsync_WhenSessionMissing_ReturnsSessionNotFound()
|
||||
{
|
||||
SessionRegistry registry = new SessionRegistry();
|
||||
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
|
||||
SessionRegistry registry = new();
|
||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
||||
|
||||
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
|
||||
new AcknowledgeAlarmRequest
|
||||
@@ -37,11 +32,11 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_forwards_guid_and_returns_native_status()
|
||||
public async Task AcknowledgeAsync_WithGuidReference_ForwardsGuidAndReturnsNativeStatus()
|
||||
{
|
||||
SessionRegistry registry = new SessionRegistry();
|
||||
SessionRegistry registry = new();
|
||||
Guid alarmGuid = Guid.NewGuid();
|
||||
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient
|
||||
FakeAlarmWorkerClient worker = new()
|
||||
{
|
||||
ReplyFactory = command =>
|
||||
{
|
||||
@@ -63,7 +58,7 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
session.MarkReady();
|
||||
registry.TryAdd(session);
|
||||
|
||||
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
|
||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
||||
|
||||
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
|
||||
new AcknowledgeAlarmRequest
|
||||
@@ -84,10 +79,10 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_propagates_worker_diagnostic_on_failure()
|
||||
public async Task AcknowledgeAsync_WhenWorkerFails_PropagatesWorkerDiagnostic()
|
||||
{
|
||||
SessionRegistry registry = new SessionRegistry();
|
||||
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient
|
||||
SessionRegistry registry = new();
|
||||
FakeAlarmWorkerClient worker = new()
|
||||
{
|
||||
ReplyFactory = _ => new MxCommandReply
|
||||
{
|
||||
@@ -106,7 +101,7 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
session.MarkReady();
|
||||
registry.TryAdd(session);
|
||||
|
||||
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
|
||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
||||
|
||||
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
|
||||
new AcknowledgeAlarmRequest
|
||||
@@ -125,7 +120,7 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
[InlineData("Galaxy!TestArea.TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "TestMachine_001.TestAlarm001")]
|
||||
[InlineData("Galaxy!Area.Tag", "Galaxy", "Area", "Tag")]
|
||||
[InlineData("Provider!Group.Tag.With.Dots", "Provider", "Group", "Tag.With.Dots")]
|
||||
public void TryParseAlarmReference_decomposes_provider_group_tag(
|
||||
public void TryParseAlarmReference_WithProviderGroupTag_DecomposesParts(
|
||||
string reference, string expectedProvider, string expectedGroup, string expectedName)
|
||||
{
|
||||
Assert.True(WorkerAlarmRpcDispatcher.TryParseAlarmReference(
|
||||
@@ -145,18 +140,18 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
[InlineData("Galaxy!Group")] // missing dot
|
||||
[InlineData("Galaxy!.Tag")] // empty group
|
||||
[InlineData("Galaxy!Group.")] // empty tag
|
||||
public void TryParseAlarmReference_rejects_malformed_references(string? reference)
|
||||
public void TryParseAlarmReference_WithMalformedReference_ReturnsFalse(string? reference)
|
||||
{
|
||||
Assert.False(WorkerAlarmRpcDispatcher.TryParseAlarmReference(
|
||||
reference, out _, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_routes_provider_group_tag_via_AckByName()
|
||||
public async Task AcknowledgeAsync_WithProviderGroupTagReference_RoutesViaAckByName()
|
||||
{
|
||||
SessionRegistry registry = new SessionRegistry();
|
||||
SessionRegistry registry = new();
|
||||
AcknowledgeAlarmByNameCommand? observed = null;
|
||||
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient
|
||||
FakeAlarmWorkerClient worker = new()
|
||||
{
|
||||
ReplyFactory = command =>
|
||||
{
|
||||
@@ -176,7 +171,7 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
session.MarkReady();
|
||||
registry.TryAdd(session);
|
||||
|
||||
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
|
||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
||||
|
||||
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
|
||||
new AcknowledgeAlarmRequest
|
||||
@@ -199,16 +194,16 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_returns_invalid_request_for_unparseable_reference()
|
||||
public async Task AcknowledgeAsync_WithUnparseableReference_ReturnsInvalidRequest()
|
||||
{
|
||||
SessionRegistry registry = new SessionRegistry();
|
||||
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient();
|
||||
SessionRegistry registry = new();
|
||||
FakeAlarmWorkerClient worker = new();
|
||||
GatewaySession session = NewSession("s1");
|
||||
session.AttachWorkerClient(worker);
|
||||
session.MarkReady();
|
||||
registry.TryAdd(session);
|
||||
|
||||
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
|
||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
||||
|
||||
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
|
||||
new AcknowledgeAlarmRequest
|
||||
@@ -223,10 +218,10 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_yields_each_snapshot_from_payload()
|
||||
public async Task QueryActiveAlarmsAsync_WithPayloadSnapshots_YieldsEachSnapshot()
|
||||
{
|
||||
SessionRegistry registry = new SessionRegistry();
|
||||
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient
|
||||
SessionRegistry registry = new();
|
||||
FakeAlarmWorkerClient worker = new()
|
||||
{
|
||||
ReplyFactory = command =>
|
||||
{
|
||||
@@ -257,9 +252,9 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
session.MarkReady();
|
||||
registry.TryAdd(session);
|
||||
|
||||
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
|
||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
||||
|
||||
List<ActiveAlarmSnapshot> collected = new List<ActiveAlarmSnapshot>();
|
||||
List<ActiveAlarmSnapshot> collected = new();
|
||||
await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync(
|
||||
new QueryActiveAlarmsRequest { SessionId = "s1" },
|
||||
CancellationToken.None))
|
||||
@@ -273,12 +268,12 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_yields_empty_when_session_missing()
|
||||
public async Task QueryActiveAlarmsAsync_WhenSessionMissing_YieldsEmpty()
|
||||
{
|
||||
SessionRegistry registry = new SessionRegistry();
|
||||
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
|
||||
SessionRegistry registry = new();
|
||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
||||
|
||||
List<ActiveAlarmSnapshot> collected = new List<ActiveAlarmSnapshot>();
|
||||
List<ActiveAlarmSnapshot> collected = new();
|
||||
await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync(
|
||||
new QueryActiveAlarmsRequest { SessionId = "missing" },
|
||||
CancellationToken.None))
|
||||
@@ -290,10 +285,10 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_yields_empty_on_worker_failure()
|
||||
public async Task QueryActiveAlarmsAsync_WhenWorkerFails_YieldsEmpty()
|
||||
{
|
||||
SessionRegistry registry = new SessionRegistry();
|
||||
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient
|
||||
SessionRegistry registry = new();
|
||||
FakeAlarmWorkerClient worker = new()
|
||||
{
|
||||
ReplyFactory = _ => new MxCommandReply
|
||||
{
|
||||
@@ -310,9 +305,9 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
session.MarkReady();
|
||||
registry.TryAdd(session);
|
||||
|
||||
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
|
||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
||||
|
||||
List<ActiveAlarmSnapshot> collected = new List<ActiveAlarmSnapshot>();
|
||||
List<ActiveAlarmSnapshot> collected = new();
|
||||
await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync(
|
||||
new QueryActiveAlarmsRequest { SessionId = "s1" },
|
||||
CancellationToken.None))
|
||||
@@ -325,7 +320,7 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
|
||||
private static GatewaySession NewSession(string sessionId)
|
||||
{
|
||||
return new GatewaySession(
|
||||
return new(
|
||||
sessionId,
|
||||
"mxaccess",
|
||||
$"mxaccess-gateway-1-{sessionId}",
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Buffers.Binary;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Workers;
|
||||
|
||||
namespace MxGateway.Tests.Gateway.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for <see cref="WorkerExecutableValidator"/> PE-header architecture parsing
|
||||
/// (finding Server-013). The validator reads the DOS <c>MZ</c> stub, follows the PE
|
||||
/// header offset at <c>0x3c</c>, checks the <c>PE\0\0</c> signature, and compares the
|
||||
/// machine field against the required <see cref="WorkerArchitecture"/>.
|
||||
/// </summary>
|
||||
public sealed class WorkerExecutableValidatorTests : IDisposable
|
||||
{
|
||||
private const ushort ImageFileMachineI386 = 0x014c;
|
||||
private const ushort ImageFileMachineAmd64 = 0x8664;
|
||||
|
||||
private readonly List<string> _tempFiles = [];
|
||||
|
||||
[Fact]
|
||||
public void Validate_X86ExecutableMatchingRequiredArchitecture_DoesNotThrow()
|
||||
{
|
||||
string path = WritePeFile(ImageFileMachineI386);
|
||||
|
||||
WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_X64ExecutableMatchingRequiredArchitecture_DoesNotThrow()
|
||||
{
|
||||
string path = WritePeFile(ImageFileMachineAmd64);
|
||||
|
||||
WorkerExecutableValidator.Validate(path, WorkerArchitecture.X64);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_X64ExecutableWhenX86Required_ThrowsInvalidExecutable()
|
||||
{
|
||||
string path = WritePeFile(ImageFileMachineAmd64);
|
||||
|
||||
WorkerProcessLaunchException exception = Assert.Throws<WorkerProcessLaunchException>(
|
||||
() => WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86));
|
||||
|
||||
Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode);
|
||||
Assert.Contains("architecture", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_X86ExecutableWhenX64Required_ThrowsInvalidExecutable()
|
||||
{
|
||||
string path = WritePeFile(ImageFileMachineI386);
|
||||
|
||||
WorkerProcessLaunchException exception = Assert.Throws<WorkerProcessLaunchException>(
|
||||
() => WorkerExecutableValidator.Validate(path, WorkerArchitecture.X64));
|
||||
|
||||
Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_FileWithoutMzHeader_ThrowsInvalidExecutable()
|
||||
{
|
||||
byte[] bytes = new byte[0x80];
|
||||
// Leave the first two bytes as zero so the MZ signature check fails.
|
||||
string path = WriteTempFile(bytes);
|
||||
|
||||
WorkerProcessLaunchException exception = Assert.Throws<WorkerProcessLaunchException>(
|
||||
() => WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86));
|
||||
|
||||
Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode);
|
||||
Assert.Contains("MZ", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_FileTooSmallForPeHeader_ThrowsInvalidExecutable()
|
||||
{
|
||||
string path = WriteTempFile([(byte)'M', (byte)'Z']);
|
||||
|
||||
WorkerProcessLaunchException exception = Assert.Throws<WorkerProcessLaunchException>(
|
||||
() => WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86));
|
||||
|
||||
Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_FileWithoutPeSignature_ThrowsInvalidExecutable()
|
||||
{
|
||||
// Build a valid MZ header pointing at a PE offset that holds a wrong signature.
|
||||
byte[] bytes = new byte[0x100];
|
||||
bytes[0] = (byte)'M';
|
||||
bytes[1] = (byte)'Z';
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(0x3c, sizeof(int)), 0x80);
|
||||
// PE region left as zeros — the "PE\0\0" signature check fails.
|
||||
string path = WriteTempFile(bytes);
|
||||
|
||||
WorkerProcessLaunchException exception = Assert.Throws<WorkerProcessLaunchException>(
|
||||
() => WorkerExecutableValidator.Validate(path, WorkerArchitecture.X86));
|
||||
|
||||
Assert.Equal(WorkerProcessLaunchErrorCode.InvalidExecutable, exception.ErrorCode);
|
||||
Assert.Contains("PE", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private string WritePeFile(ushort machine)
|
||||
{
|
||||
const int peHeaderOffset = 0x80;
|
||||
byte[] bytes = new byte[peHeaderOffset + 6];
|
||||
bytes[0] = (byte)'M';
|
||||
bytes[1] = (byte)'Z';
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(0x3c, sizeof(int)), peHeaderOffset);
|
||||
bytes[peHeaderOffset] = (byte)'P';
|
||||
bytes[peHeaderOffset + 1] = (byte)'E';
|
||||
bytes[peHeaderOffset + 2] = 0;
|
||||
bytes[peHeaderOffset + 3] = 0;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(peHeaderOffset + 4, sizeof(ushort)), machine);
|
||||
return WriteTempFile(bytes);
|
||||
}
|
||||
|
||||
private string WriteTempFile(byte[] bytes)
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"mxgw-pe-{Guid.NewGuid():N}.bin");
|
||||
File.WriteAllBytes(path, bytes);
|
||||
_tempFiles.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string path in _tempFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort cleanup of the temp PE fixtures.
|
||||
}
|
||||
}
|
||||
|
||||
_tempFiles.Clear();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Makes the xUnit parallelism policy explicit (Tests-012): test collections run in
|
||||
parallel, so WebApplication-building tests must keep binding ephemeral ports
|
||||
(http://127.0.0.1:0) to avoid a future fixed-port collision. -->
|
||||
<None Update="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxGateway.Contracts\MxGateway.Contracts.csproj" />
|
||||
<ProjectReference Include="..\MxGateway.Server\MxGateway.Server.csproj" />
|
||||
|
||||
@@ -150,6 +150,32 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
||||
Assert.Equal("matched active key", record.Details);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="AuthSqliteConnectionFactory.OpenConnectionAsync"/> opens
|
||||
/// the auth database in WAL journal mode so concurrent readers and writers degrade
|
||||
/// gracefully instead of surfacing <c>SQLITE_BUSY</c> on the request path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OpenConnectionAsync_EnablesWalJournalModeAndBusyTimeout()
|
||||
{
|
||||
string databasePath = CreateTempDatabasePath();
|
||||
await using ServiceProvider services = BuildAuthServices(databasePath);
|
||||
AuthSqliteConnectionFactory factory = services.GetRequiredService<AuthSqliteConnectionFactory>();
|
||||
|
||||
await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None);
|
||||
|
||||
await using SqliteCommand journalModeCommand = connection.CreateCommand();
|
||||
journalModeCommand.CommandText = "PRAGMA journal_mode;";
|
||||
string? journalMode = (string?)await journalModeCommand.ExecuteScalarAsync(CancellationToken.None);
|
||||
|
||||
await using SqliteCommand busyTimeoutCommand = connection.CreateCommand();
|
||||
busyTimeoutCommand.CommandText = "PRAGMA busy_timeout;";
|
||||
long busyTimeout = (long)(await busyTimeoutCommand.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
|
||||
|
||||
Assert.Equal("wal", journalMode, ignoreCase: true);
|
||||
Assert.True(busyTimeout > 0, $"Expected a non-zero busy_timeout but found {busyTimeout}.");
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildAuthServices(string databasePath)
|
||||
{
|
||||
IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||
|
||||
+3
-120
@@ -10,6 +10,7 @@ using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Tests.Security.Authorization;
|
||||
|
||||
@@ -107,7 +108,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
() => interceptor.ServerStreamingServerHandler(
|
||||
new StreamEventsRequest(),
|
||||
new TestServerStreamWriter<MxEvent>(),
|
||||
new RecordingServerStreamWriter<MxEvent>(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||
(_, _, _) => Task.CompletedTask));
|
||||
|
||||
@@ -123,7 +124,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
||||
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)),
|
||||
identityAccessor);
|
||||
TestServerStreamWriter<MxEvent> streamWriter = new();
|
||||
RecordingServerStreamWriter<MxEvent> streamWriter = new();
|
||||
|
||||
await interceptor.ServerStreamingServerHandler(
|
||||
new StreamEventsRequest(),
|
||||
@@ -396,40 +397,6 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Constraint enforcer that permits every operation for composition tests.</summary>
|
||||
private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
string target,
|
||||
ConstraintFailure failure,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
|
||||
{
|
||||
/// <summary>Gets whether the verifier was called.</summary>
|
||||
@@ -453,88 +420,4 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestServerStreamWriter<T> : IServerStreamWriter<T>
|
||||
{
|
||||
/// <summary>Gets messages written to the stream.</summary>
|
||||
public List<T> Messages { get; } = [];
|
||||
|
||||
/// <summary>Gets or sets write options for the stream.</summary>
|
||||
public WriteOptions? WriteOptions { get; set; }
|
||||
|
||||
/// <summary>Writes a message to the stream.</summary>
|
||||
/// <param name="message">The message to write.</param>
|
||||
/// <returns>Task representing the write operation.</returns>
|
||||
public Task WriteAsync(T message)
|
||||
{
|
||||
Messages.Add(message);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestServerCallContext(
|
||||
Metadata requestHeaders,
|
||||
CancellationToken cancellationToken = default) : ServerCallContext
|
||||
{
|
||||
private readonly Metadata responseTrailers = [];
|
||||
private readonly Dictionary<object, object> userState = [];
|
||||
private Status status;
|
||||
private WriteOptions? writeOptions;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string HostCore => "localhost";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Metadata RequestHeadersCore => requestHeaders;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override CancellationToken CancellationTokenCore => cancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Metadata ResponseTrailersCore => responseTrailers;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Status StatusCore
|
||||
{
|
||||
get => status;
|
||||
set => status = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override WriteOptions? WriteOptionsCore
|
||||
{
|
||||
get => writeOptions;
|
||||
set => writeOptions = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override AuthContext AuthContextCore { get; } = new(
|
||||
string.Empty,
|
||||
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IDictionary<object, object> UserStateCore => userState;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(
|
||||
ContextPropagationOptions? options)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
|
||||
namespace MxGateway.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IConstraintEnforcer"/> that permits every operation, for tests that
|
||||
/// exercise gRPC service or interceptor behaviour without constraint policy.
|
||||
/// </summary>
|
||||
public sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
string target,
|
||||
ConstraintFailure failure,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Grpc.Core;
|
||||
|
||||
namespace MxGateway.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe <see cref="IServerStreamWriter{T}"/> that records every written message
|
||||
/// and lets a test await the first message with a timeout.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The streamed message type.</typeparam>
|
||||
public sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
|
||||
{
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly TaskCompletionSource<T> _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly List<T> _messages = [];
|
||||
|
||||
/// <summary>Gets the messages written to this stream, in order.</summary>
|
||||
public IReadOnlyList<T> Messages
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _messages.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets options for writing messages to the stream.</summary>
|
||||
public WriteOptions? WriteOptions { get; set; }
|
||||
|
||||
/// <summary>Records the supplied message.</summary>
|
||||
/// <param name="message">The message to record.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task WriteAsync(T message)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_messages.Add(message);
|
||||
}
|
||||
|
||||
_firstMessage.TrySetResult(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Waits for the first message to be written within the specified timeout.</summary>
|
||||
/// <param name="timeout">Maximum time to wait for the first message.</param>
|
||||
/// <returns>The first message written to this stream.</returns>
|
||||
public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout) =>
|
||||
await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Grpc.Core;
|
||||
|
||||
namespace MxGateway.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-memory <see cref="ServerCallContext"/> for exercising gRPC service
|
||||
/// implementations directly in unit tests, without a real gRPC transport.
|
||||
/// </summary>
|
||||
public sealed class TestServerCallContext : ServerCallContext
|
||||
{
|
||||
private readonly Metadata _requestHeaders;
|
||||
private readonly Metadata _responseTrailers = [];
|
||||
private readonly Dictionary<object, object> _userState = [];
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
private Status _status;
|
||||
private WriteOptions? _writeOptions;
|
||||
|
||||
/// <summary>Initializes the context with the supplied request headers and cancellation token.</summary>
|
||||
/// <param name="requestHeaders">Request headers visible to the service; defaults to empty.</param>
|
||||
/// <param name="cancellationToken">Cancellation token surfaced to the service.</param>
|
||||
public TestServerCallContext(Metadata? requestHeaders = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_requestHeaders = requestHeaders ?? [];
|
||||
_cancellationToken = cancellationToken;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string HostCore => "localhost";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Metadata RequestHeadersCore => _requestHeaders;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override CancellationToken CancellationTokenCore => _cancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Metadata ResponseTrailersCore => _responseTrailers;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Status StatusCore
|
||||
{
|
||||
get => _status;
|
||||
set => _status = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override WriteOptions? WriteOptionsCore
|
||||
{
|
||||
get => _writeOptions;
|
||||
set => _writeOptions = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override AuthContext AuthContextCore { get; } = new(
|
||||
string.Empty,
|
||||
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IDictionary<object, object> UserStateCore => _userState;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"appDomain": "denied",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1,
|
||||
"longRunningTestSeconds": 30
|
||||
}
|
||||
@@ -32,4 +32,16 @@ public sealed class WorkerLogRedactorTests
|
||||
Assert.Equal("[redacted]", redacted["api_key"]);
|
||||
Assert.Equal("session-1", redacted["session_id"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="WorkerLogRedactor.RedactValue"/> redacts individual
|
||||
/// credential-bearing fields before they reach a log sink.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RedactValue_WithCredentialBearingFieldNames_ReturnsRedactedValue()
|
||||
{
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("credential_value", "secret"));
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("password_value", "secret"));
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("secured_write_token", "secret"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using Google.Protobuf;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Bootstrap;
|
||||
using MxGateway.Worker.Conversion;
|
||||
using ProtobufTimestamp = Google.Protobuf.WellKnownTypes.Timestamp;
|
||||
|
||||
@@ -60,6 +59,26 @@ public sealed class VariantConverterTests
|
||||
Assert.Equal("VT_I8", converted.VariantType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker-010 regression: a 32-bit <see cref="uint"/> with an expected
|
||||
/// data type of <see cref="MxDataType.Time"/> must not be projected as a
|
||||
/// Windows FILETIME. A uint can only hold the low 32 bits of a FILETIME,
|
||||
/// which would silently render as a near-1601 timestamp; the converter
|
||||
/// must fall through to an integer projection instead.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Convert_WithUInt32AndExpectedTime_DoesNotProjectFileTime()
|
||||
{
|
||||
const uint value = 123456789u;
|
||||
|
||||
MxValue converted = _converter.Convert(value, MxDataType.Time);
|
||||
|
||||
Assert.Equal(MxDataType.Integer, converted.DataType);
|
||||
Assert.Equal(MxValue.KindOneofCase.Int64Value, converted.KindCase);
|
||||
Assert.Equal(value, converted.Int64Value);
|
||||
Assert.Equal("VT_UI4", converted.VariantType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null-like values preserve their null semantics and variant type.</summary>
|
||||
/// <param name="value">Null-like value to convert.</param>
|
||||
/// <param name="expectedVariantType">Expected variant type string.</param>
|
||||
@@ -172,15 +191,6 @@ public sealed class VariantConverterTests
|
||||
Assert.Contains(typeof(UnsupportedVariant).FullName!, converted.ArrayValue.RawDiagnostic);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that credential-bearing fields are redacted before logging.</summary>
|
||||
[Fact]
|
||||
public void Redactor_WithCredentialBearingValueFields_RedactsBeforeLogging()
|
||||
{
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("credential_value", "secret"));
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("password_value", "secret"));
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("secured_write_token", "secret"));
|
||||
}
|
||||
|
||||
/// <summary>Fake unsupported variant type for testing unknown type handling.</summary>
|
||||
private sealed class UnsupportedVariant
|
||||
{
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf;
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Ipc;
|
||||
using MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Worker.Tests.Ipc;
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
WorkerEnvelope envelope = CreateGatewayHelloEnvelope();
|
||||
envelope.ProtocolVersion++;
|
||||
using MemoryStream stream = new(CreateFrame(envelope));
|
||||
using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(envelope));
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
@@ -55,7 +55,7 @@ public sealed class WorkerFrameProtocolTests
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
WorkerEnvelope envelope = CreateGatewayHelloEnvelope();
|
||||
envelope.SessionId = "different-session";
|
||||
using MemoryStream stream = new(CreateFrame(envelope));
|
||||
using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(envelope));
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
@@ -65,9 +65,15 @@ public sealed class WorkerFrameProtocolTests
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.SessionMismatch, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that malformed length throws error.</summary>
|
||||
/// <summary>
|
||||
/// Verifies that a frame whose length prefix is zero is rejected before the
|
||||
/// payload buffer is allocated. <c>docs/WorkerFrameProtocol.md</c> states the
|
||||
/// reader rejects zero-length payloads as a malformed-length error. The
|
||||
/// length prefix is the leading four bytes of the stream, so a four-zero-byte
|
||||
/// stream is exactly a frame declaring a zero-length payload.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithMalformedLength_ThrowsMalformedLength()
|
||||
public async Task ReadAsync_WithZeroLengthPayload_ThrowsMalformedLength()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream stream = new(new byte[sizeof(uint)]);
|
||||
@@ -80,12 +86,40 @@ public sealed class WorkerFrameProtocolTests
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.MalformedLength, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a frame whose length prefix exceeds the configured maximum
|
||||
/// is rejected before the payload buffer is allocated. <c>docs/WorkerFrameProtocol.md</c>
|
||||
/// states the reader rejects oversized payloads as a message-too-large error.
|
||||
/// A small maximum is configured so the rejection is asserted without
|
||||
/// allocating a multi-megabyte buffer.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithPayloadAboveConfiguredMaximum_ThrowsMessageTooLarge()
|
||||
{
|
||||
const int maxMessageBytes = 64;
|
||||
WorkerFrameProtocolOptions options = new(
|
||||
SessionId,
|
||||
GatewayContractInfo.WorkerProtocolVersion,
|
||||
Nonce,
|
||||
maxMessageBytes);
|
||||
byte[] frame = new byte[sizeof(uint)];
|
||||
WorkerFrameTestHelpers.WriteUInt32LittleEndian(frame, maxMessageBytes + 1);
|
||||
using MemoryStream stream = new(frame);
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
|
||||
async () => await reader.ReadAsync());
|
||||
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that malformed payload throws invalid envelope error.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithMalformedPayload_ThrowsInvalidEnvelope()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream stream = new(CreateFrame(new byte[] { 0x80 }));
|
||||
using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(new byte[] { 0x80 }));
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
@@ -118,6 +152,39 @@ public sealed class WorkerFrameProtocolTests
|
||||
Assert.Equal(new ulong[] { 1, 2, 3 }, new[] { first.Sequence, second.Sequence, third.Sequence }.OrderBy(sequence => sequence));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker-009 regression: the reader rents its payload buffer from a
|
||||
/// shared pool, so a rented buffer can be larger than the current frame
|
||||
/// and may carry bytes from a previous, larger frame. Reading frames of
|
||||
/// differing sizes back-to-back through one reader must parse each frame
|
||||
/// using only its own payload length, never trailing pooled bytes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithVaryingFrameSizes_ParsesEachFrameExactly()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream stream = new();
|
||||
WorkerFrameWriter writer = new(stream, options);
|
||||
|
||||
// A large-payload frame followed by a small-payload frame: if the
|
||||
// reader reused a pooled buffer without honouring the second frame's
|
||||
// length, the small frame would parse with stale trailing bytes.
|
||||
WorkerEnvelope large = CreateGatewayHelloEnvelope(sequence: 1);
|
||||
large.GatewayHello.GatewayVersion = new string('x', 4096);
|
||||
WorkerEnvelope small = CreateGatewayHelloEnvelope(sequence: 2);
|
||||
|
||||
await writer.WriteAsync(large);
|
||||
await writer.WriteAsync(small);
|
||||
stream.Position = 0;
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerEnvelope firstParsed = await reader.ReadAsync();
|
||||
WorkerEnvelope secondParsed = await reader.ReadAsync();
|
||||
|
||||
Assert.Equal(large, firstParsed);
|
||||
Assert.Equal(small, secondParsed);
|
||||
}
|
||||
|
||||
private static WorkerFrameProtocolOptions CreateOptions()
|
||||
{
|
||||
return new WorkerFrameProtocolOptions(
|
||||
@@ -142,27 +209,4 @@ public sealed class WorkerFrameProtocolTests
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateFrame(IMessage message)
|
||||
{
|
||||
return CreateFrame(message.ToByteArray());
|
||||
}
|
||||
|
||||
private static byte[] CreateFrame(byte[] payload)
|
||||
{
|
||||
byte[] frame = new byte[sizeof(uint) + payload.Length];
|
||||
WriteUInt32LittleEndian(frame, (uint)payload.Length);
|
||||
payload.CopyTo(frame, sizeof(uint));
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static void WriteUInt32LittleEndian(
|
||||
byte[] buffer,
|
||||
uint value)
|
||||
{
|
||||
buffer[0] = (byte)value;
|
||||
buffer[1] = (byte)(value >> 8);
|
||||
buffer[2] = (byte)(value >> 16);
|
||||
buffer[3] = (byte)(value >> 24);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Threading;
|
||||
@@ -9,8 +8,7 @@ using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Bootstrap;
|
||||
using MxGateway.Worker.Ipc;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
using MxGateway.Worker.Sta;
|
||||
using MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Worker.Tests.Ipc;
|
||||
|
||||
@@ -213,100 +211,4 @@ public sealed class WorkerPipeClientTests
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeRuntimeSession : IWorkerRuntimeSession
|
||||
{
|
||||
/// <summary>Starts the worker session.</summary>
|
||||
/// <param name="sessionId">Session ID.</param>
|
||||
/// <param name="workerProcessId">Worker process ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Worker ready response.</returns>
|
||||
public Task<WorkerReady> StartAsync(
|
||||
string sessionId,
|
||||
int workerProcessId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new WorkerReady
|
||||
{
|
||||
WorkerProcessId = workerProcessId,
|
||||
MxaccessProgid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.ProgId,
|
||||
MxaccessClsid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.Clsid,
|
||||
ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Dispatches a command to STA thread.</summary>
|
||||
/// <param name="command">The command.</param>
|
||||
/// <returns>Command reply.</returns>
|
||||
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||
{
|
||||
return Task.FromResult(new MxCommandReply
|
||||
{
|
||||
SessionId = command.SessionId,
|
||||
CorrelationId = command.CorrelationId,
|
||||
Kind = command.Kind,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.Ok,
|
||||
Message = "OK",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Captures current runtime heartbeat snapshot.</summary>
|
||||
/// <returns>Heartbeat snapshot.</returns>
|
||||
public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat()
|
||||
{
|
||||
return new WorkerRuntimeHeartbeatSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
currentCommandCorrelationId: string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Drains queued events.</summary>
|
||||
/// <param name="maxEvents">Maximum events to drain.</param>
|
||||
/// <returns>Drained events.</returns>
|
||||
public IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents)
|
||||
{
|
||||
return Array.Empty<WorkerEvent>();
|
||||
}
|
||||
|
||||
/// <summary>Drains pending fault if any.</summary>
|
||||
/// <returns>Fault or null.</returns>
|
||||
public WorkerFault? DrainFault()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Cancels a command by correlation ID.</summary>
|
||||
/// <param name="correlationId">Command correlation ID.</param>
|
||||
/// <returns>True if cancelled.</returns>
|
||||
public bool CancelCommand(string correlationId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Requests graceful shutdown.</summary>
|
||||
public void RequestShutdown()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Shuts down gracefully within timeout.</summary>
|
||||
/// <param name="timeout">Shutdown timeout.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Shutdown result.</returns>
|
||||
public Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>()));
|
||||
}
|
||||
|
||||
/// <summary>Disposes resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user