Compare commits

...

6 Commits

Author SHA1 Message Date
Joseph Doherty 61c00d59a9 feat(gateway): readiness check for the worker executable (#206)
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m27s
ci / java (push) Successful in 2m13s
ci / portable (push) Failing after 4m25s
Readiness covered only the auth store, so a gateway whose worker exe was
missing or of the wrong architecture reported ready while every OpenSession
failed. The new ready-tier check worker-executable applies the launcher's own
rule (path resolution, .exe, presence, PE architecture), now shared as
WorkerExecutableValidator.ResolveAndValidate, and reports only the launch
error code (the endpoint is anonymous). The session check stays on the
active tier, so zero sessions keeps readiness green.

The bounded Galaxy SQL check is not in this change: its tier, failure status
and probe cadence need a decision (see the issue).

Tests: WorkerExecutableHealthCheckTests (healthy, missing, wrong architecture,
and the ready-tier registration, which fails without the wiring).
2026-11-06 16:13:09 -05:00
Joseph Doherty 574369b666 feat(mxgw-go): add -require-certificate-validation for strict TLS (#199)
Over TLS without -ca-cert the CLI was always skip-verify, with no way to ask
for verification against the system roots even though the library supports
it. The new common flag is wired to Options.RequireCertificateValidation in
both the gateway and Galaxy dialers and echoed in the redacted JSON options.

Test: tls_flag_test.go starts a self-signed TLS gRPC gateway; the default
open-session succeeds (positive control) and the same call with the flag
fails to dial (fails on the old code with 'flag provided but not defined').
2026-11-06 16:10:39 -05:00
Joseph Doherty 9351ec1d2c feat(client-go): typed Unauthenticated vs PermissionDenied errors (#198)
Every RPC failure surfaced as a GatewayError with no way to tell a rejected
key from a missing scope, though docs/ClientLibrariesDesign.md requires the
two be treated distinctly. GatewayError now matches the new sentinels
ErrUnauthenticated and ErrPermissionDenied via errors.Is (by gRPC status code
only, so it works through the secret-redaction wrapper) and exposes Code().

Tests: errors_auth_test.go — sentinel matching per code, Code(), and a bufconn
AcknowledgeAlarm round trip proving the match survives the wire and the
password stays redacted (both behavioural tests fail without GatewayError.Is).
2026-11-06 16:08:51 -05:00
Joseph Doherty b9096f7c57 fix(client-dotnet): size the safe-unary retry budget for every attempt (#197)
ExecuteSafeUnaryAsync capped the whole retry pipeline at one
DefaultCallTimeout while each attempt carried the same deadline, so a first
attempt ending in DeadlineExceeded spent the entire budget and the retry
never ran. The outer cap is now MaxAttempts x DefaultCallTimeout plus MaxDelay
per backoff (MxGatewayClientRetryPolicy.SafeUnaryBudget), in both
MxGatewayClient and GalaxyRepositoryClient; a budget too large for a
cancellation timer leaves only the per-attempt deadlines.

Tests: MxGatewayClientWireTests.SafeUnaryRetry_AfterDeadlineExceededFirstAttempt_RunsTheRetry
(real gRPC loopback server stalls the first Invoke; fails with Cancelled on
the old code), plus two SafeUnaryBudget unit tests.
2026-11-06 16:07:35 -05:00
Joseph Doherty ff397b27be fix(gateway): remove the worker startup-probe retry options that could never retry (#188)
WorkerProcessStartedProbe only checks HasExited and throws a non-retryable
WorkerProcessLaunchException, so StartupProbeRetryAttempts and
StartupProbeRetryDelayMilliseconds configured a Polly retry that no shipped
probe could trigger. Real readiness is WorkerClient's WorkerReady handshake
under the same StartupTimeoutSeconds budget, so the probe now runs once and
the two options, their validation, the Polly pipeline (and the server's now
unused Polly.Core reference) and their docs are removed. A stale value in
configuration is ignored by the binder.

Tests: WorkerProcessLauncherTests.LaunchAsync_WhenStartupProbeFails_DoesNotRetryTheProbe,
WorkerOptions_HasNoStartupProbeRetryOptions (both fail on the old code).
2026-11-06 16:05:42 -05:00
Joseph Doherty 3a3d9e8e06 fix(worker): reply WorkerUnavailable to commands that race shutdown (#182)
TryStartCommandTask returned silently when command intake had stopped, so a
command refused during shutdown got no reply and no log and the gateway's
correlation wait ran out its own timeout. The refusal now writes a
WorkerUnavailable reply for the correlation and logs
WorkerCommandRejectedAfterShutdown. StopAcceptingCommands is internal so the
test can reach the guard (the serial read loop cannot race it today).

Test: WorkerPipeSessionTests.CommandAfterShutdown_RepliesWorkerUnavailable.
2026-11-06 16:03:47 -05:00
32 changed files with 821 additions and 167 deletions
+5
View File
@@ -122,6 +122,11 @@ The .NET client applies a bounded Polly retry policy only to idempotent calls:
streams, writes, secured writes, authentication, registration, item management,
or subscription changes because those calls can partially succeed in MXAccess.
Each attempt carries its own `DefaultCallTimeout` deadline. The overall budget
for a retried call is `MaxAttempts × DefaultCallTimeout` plus `MaxDelay` for each
backoff, so an attempt that ends in `DeadlineExceeded` leaves the retry its own
full deadline instead of consuming the whole budget.
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
library constructor unless a helper explicitly says it does that.
+3 -1
View File
@@ -157,7 +157,9 @@ worker; call until empty). `DisposeAsync` closes the session best-effort and doe
not throw when the gateway is unreachable or the client is already disposed.
Retry applies only to idempotent calls (`CloseSessionRawAsync`, ping,
session-state and worker-info commands, Galaxy reads). `AcknowledgeAlarmAsync`
session-state and worker-info commands, Galaxy reads). Each attempt gets its own
`DefaultCallTimeout`; a retried call can take up to `Retry.MaxAttempts ×
DefaultCallTimeout` plus the backoff between attempts. `AcknowledgeAlarmAsync`
is never retried because it is a non-idempotent operator write that re-sends the
operator password and can double-acknowledge in the Historian.
@@ -2,6 +2,39 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientOptionsTests
{
/// <summary>
/// The safe-unary budget covers every attempt's deadline plus the backoff between them, so a
/// first attempt that hits its deadline does not consume the retry's time (#197).
/// </summary>
[Fact]
public void SafeUnaryBudget_CoversEveryAttemptAndBackoff()
{
var options = new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
DefaultCallTimeout = TimeSpan.FromSeconds(30),
Retry = new MxGatewayClientRetryOptions { MaxAttempts = 3, MaxDelay = TimeSpan.FromSeconds(2) },
};
Assert.Equal(TimeSpan.FromSeconds(94), MxGatewayClientRetryPolicy.SafeUnaryBudget(options));
}
/// <summary>A budget beyond what a cancellation timer accepts means no outer cap rather than a throw.</summary>
[Fact]
public void SafeUnaryBudget_WhenTooLargeForATimer_ReturnsNull()
{
var options = new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
DefaultCallTimeout = TimeSpan.FromDays(40),
Retry = new MxGatewayClientRetryOptions { MaxAttempts = 2 },
};
Assert.Null(MxGatewayClientRetryPolicy.SafeUnaryBudget(options));
}
/// <summary>Verifies that options with valid endpoint and API key pass validation.</summary>
[Fact]
public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds()
@@ -90,6 +90,41 @@ public sealed class MxGatewayClientWireTests
server.Service.AuthorizationByMethod);
}
/// <summary>
/// A safe unary call whose first attempt runs into its deadline must still get its retry:
/// the outer budget covers every attempt, not just one DefaultCallTimeout (#197).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SafeUnaryRetry_AfterDeadlineExceededFirstAttempt_RunsTheRetry()
{
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync(service =>
service.StallFirstInvokes = 1);
await using MxGatewayClient client = server.CreateClient(
ApiKey,
TimeSpan.FromMilliseconds(500),
new MxGatewayClientRetryOptions
{
MaxAttempts = 2,
Delay = TimeSpan.FromMilliseconds(10),
MaxDelay = TimeSpan.FromMilliseconds(10),
UseJitter = false,
});
MxCommandReply reply = await client.InvokeAsync(new MxCommandRequest
{
SessionId = WireFakeGatewayServer.FakeGatewayService.SessionId,
Command = new MxCommand
{
Kind = MxCommandKind.Ping,
Ping = new PingCommand(),
},
});
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(2, server.Service.InvokeCount);
}
/// <summary>
/// Verifies the gateway's replay-gap sentinel survives serialization and is
/// surfaced as a typed, non-terminal stream item.
@@ -88,12 +88,26 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
/// <param name="apiKey">API key the client should present.</param>
/// <returns>A client that talks to this server over h2c.</returns>
public MxGatewayClient CreateClient(string apiKey) =>
CreateClient(apiKey, TimeSpan.FromSeconds(30), new MxGatewayClientRetryOptions());
/// <summary>
/// Creates a client bound to this server's endpoint with an explicit call timeout and retry policy.
/// </summary>
/// <param name="apiKey">API key the client should present.</param>
/// <param name="defaultCallTimeout">Per-attempt unary deadline.</param>
/// <param name="retry">Retry options for safe unary calls.</param>
/// <returns>A client that talks to this server over h2c.</returns>
public MxGatewayClient CreateClient(
string apiKey,
TimeSpan defaultCallTimeout,
MxGatewayClientRetryOptions retry) =>
MxGatewayClient.Create(new MxGatewayClientOptions
{
Endpoint = Endpoint,
ApiKey = apiKey,
UseTls = false,
DefaultCallTimeout = TimeSpan.FromSeconds(30),
DefaultCallTimeout = defaultCallTimeout,
Retry = retry,
});
/// <summary>Stops and disposes the underlying web application.</summary>
@@ -145,6 +159,17 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
/// </summary>
public ReplayGap? ReplayGap { get; set; }
/// <summary>
/// Gets or sets how many leading <c>Invoke</c> calls stall until the client's deadline
/// expires, so the client sees <see cref="StatusCode.DeadlineExceeded"/> for them.
/// </summary>
public int StallFirstInvokes { get; set; }
/// <summary>Gets the number of <c>Invoke</c> calls the server received.</summary>
public int InvokeCount => Volatile.Read(ref _invokeCount);
private int _invokeCount;
/// <summary>
/// Gets the last <c>Invoke</c> request the client sent, as decoded from the wire.
/// </summary>
@@ -179,17 +204,32 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
}
/// <inheritdoc />
public override Task<MxCommandReply> Invoke(MxCommandRequest request, ServerCallContext context)
public override async Task<MxCommandReply> Invoke(MxCommandRequest request, ServerCallContext context)
{
Record(context);
InvokeRequest = request;
if (Interlocked.Increment(ref _invokeCount) <= StallFirstInvokes)
{
// Hold the call past the client's deadline; the cancellation is the deadline
// firing (or the client giving up), either way the caller has stopped waiting.
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, context.CancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
throw new RpcException(new Status(StatusCode.DeadlineExceeded, "stalled attempt"));
}
if (DenyInvoke)
{
throw new RpcException(new Status(StatusCode.PermissionDenied, "invoke scope required"));
}
return Task.FromResult(new MxCommandReply
return new MxCommandReply
{
SessionId = request.SessionId,
CorrelationId = request.ClientCorrelationId,
@@ -197,7 +237,7 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
ProtocolStatus = Ok(),
Hresult = 0,
Register = new RegisterReply { ServerHandle = ServerHandle },
});
};
}
/// <inheritdoc />
@@ -517,8 +517,15 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
Func<CancellationToken, Task<T>> call,
CancellationToken cancellationToken)
{
// Each attempt carries its own DefaultCallTimeout deadline, so the outer cap must cover
// every attempt plus the backoff between them; capping it at one DefaultCallTimeout let a
// DeadlineExceeded first attempt spend the whole budget and the retry never ran (#197).
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(Options.DefaultCallTimeout);
TimeSpan? budget = MxGatewayClientRetryPolicy.SafeUnaryBudget(Options);
if (budget is not null)
{
timeout.CancelAfter(budget.Value);
}
return await _safeUnaryRetryPipeline.ExecuteAsync(
async token => await call(token).ConfigureAwait(false),
@@ -375,8 +375,15 @@ public sealed class MxGatewayClient : IAsyncDisposable
Func<CancellationToken, Task<T>> call,
CancellationToken cancellationToken)
{
// Each attempt carries its own DefaultCallTimeout deadline, so the outer cap must cover
// every attempt plus the backoff between them; capping it at one DefaultCallTimeout let a
// DeadlineExceeded first attempt spend the whole budget and the retry never ran (#197).
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(Options.DefaultCallTimeout);
TimeSpan? budget = MxGatewayClientRetryPolicy.SafeUnaryBudget(Options);
if (budget is not null)
{
timeout.CancelAfter(budget.Value);
}
return await _safeUnaryRetryPipeline.ExecuteAsync(
async token => await call(token).ConfigureAwait(false),
@@ -41,6 +41,29 @@ internal static class MxGatewayClientRetryPolicy
.Build();
}
/// <summary>
/// Returns the overall budget for one retried safe unary call: every attempt's
/// <see cref="MxGatewayClientOptions.DefaultCallTimeout"/> deadline plus the longest backoff
/// between attempts. Returns <see langword="null"/> when the budget is too large for a
/// cancellation timer, in which case the per-attempt deadlines and the bounded attempt count
/// already bound the call.
/// </summary>
/// <param name="options">The client options supplying the call timeout and retry settings.</param>
/// <returns>The overall budget, or <see langword="null"/> for no outer cap.</returns>
public static TimeSpan? SafeUnaryBudget(MxGatewayClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);
int attempts = Math.Max(1, options.Retry.MaxAttempts);
double milliseconds = (options.DefaultCallTimeout.TotalMilliseconds * attempts)
+ (options.Retry.MaxDelay.TotalMilliseconds * (attempts - 1));
// CancellationTokenSource.CancelAfter rejects anything above uint.MaxValue - 1 ms.
return milliseconds >= uint.MaxValue - 1
? null
: TimeSpan.FromMilliseconds(milliseconds);
}
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
/// <param name="kind">The command kind to check.</param>
/// <returns><see langword="true"/> if the command kind may be automatically retried; otherwise, <see langword="false"/>.</returns>
+4 -1
View File
@@ -146,7 +146,10 @@ type MxAccessError struct { ... }
```
Use `errors.Is` / `errors.As` support. Preserve raw protobuf replies on command
errors.
errors. `GatewayError` matches the sentinels `ErrUnauthenticated` and
`ErrPermissionDenied` through `errors.Is` by gRPC status code, so callers treat
the two authorization failures distinctly (`docs/ClientLibrariesDesign.md`)
without parsing messages.
## Test CLI
+12 -2
View File
@@ -92,7 +92,13 @@ returned subscription owns cancellation and exposes `Close` for deterministic
goroutine cleanup. Raw protobuf messages remain available through the
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
errors preserve the raw reply.
errors preserve the raw reply. Authorization failures are distinct:
`errors.Is(err, mxgateway.ErrUnauthenticated)` matches a rejected API key
(gRPC `Unauthenticated` — fix the key) and `errors.Is(err,
mxgateway.ErrPermissionDenied)` matches a valid key missing a scope or another
key's session (gRPC `PermissionDenied` — grant the scope). `GatewayError.Code()`
returns the raw gRPC status code. The match reads only the status code, so it
works through the credential-redaction wrapper.
`EnsureMxAccessSuccess` follows COM semantics: only a **negative** HRESULT is a
failure, so positive success codes such as `S_FALSE` (1) pass. `StatusSucceeded`
@@ -426,7 +432,11 @@ the gateway:
Every subcommand wired into the CLI. All accept the common flags
(`-endpoint`, `-plaintext`, `-api-key` / `-api-key-env`, `-ca-cert`,
`-server-name-override`, `-call-timeout`) and most accept `-json`.
`-server-name-override`, `-call-timeout`, `-require-certificate-validation`) and
most accept `-json`. Over TLS without `-ca-cert` the CLI accepts any gateway
certificate (the self-signed default); `-require-certificate-validation` sets
`Options.RequireCertificateValidation` so the certificate must chain to the
system trust roots instead.
| Command | Purpose |
|---|---|
+8
View File
@@ -40,6 +40,10 @@ type commonOptions struct {
CACertFile string `json:"caCertFile,omitempty"`
ServerName string `json:"serverNameOverride,omitempty"`
CallTimeout string `json:"callTimeout,omitempty"`
// RequireCertificateValidation verifies the gateway certificate against the
// system trust roots when no -ca-cert is pinned, instead of the lenient
// self-signed default.
RequireCertificateValidation bool `json:"requireCertificateValidation,omitempty"`
apiKeyValue string
timeout time.Duration
@@ -1297,6 +1301,8 @@ func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
flags.StringVar(&common.CACertFile, "ca-cert", "", "CA certificate file")
flags.StringVar(&common.ServerName, "server-name-override", "", "TLS server name override")
flags.StringVar(&common.CallTimeout, "call-timeout", "30s", "per-call timeout")
flags.BoolVar(&common.RequireCertificateValidation, "require-certificate-validation", false,
"verify the gateway TLS certificate against the system trust roots when no -ca-cert is set (default: accept any certificate)")
return common
}
@@ -1313,6 +1319,7 @@ func dialForCommand(ctx context.Context, common *commonOptions) (*mxgateway.Clie
CACertFile: options.CACertFile,
ServerNameOverride: options.ServerName,
CallTimeout: options.timeout,
RequireCertificateValidation: options.RequireCertificateValidation,
})
return client, options, err
}
@@ -1529,6 +1536,7 @@ func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgatewa
CACertFile: options.CACertFile,
ServerNameOverride: options.ServerName,
CallTimeout: options.timeout,
RequireCertificateValidation: options.RequireCertificateValidation,
})
return client, options, err
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"strings"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// #199 — `--tls` without a CA used to be skip-verify with no way out from the
// CLI. -require-certificate-validation must make the CLI reject a certificate
// the system roots do not trust (the gateway's own self-signed one).
type openSessionFakeGateway struct {
pb.UnimplementedMxAccessGatewayServer
}
func (g *openSessionFakeGateway) OpenSession(context.Context, *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
return &pb.OpenSessionReply{
SessionId: "tls-session",
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}, nil
}
func startSelfSignedTLSGateway(t *testing.T) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})))
pb.RegisterMxAccessGatewayServer(server, &openSessionFakeGateway{})
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() {
server.Stop()
_ = listener.Close()
})
return listener.Addr().String()
}
func TestRequireCertificateValidationFlagRejectsSelfSignedGateway(t *testing.T) {
endpoint := startSelfSignedTLSGateway(t)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
base := []string{"open-session", "-endpoint", endpoint, "-api-key", "test", "-call-timeout", "5s"}
// Positive control: the lenient default accepts the self-signed certificate.
var okOut, okErr bytes.Buffer
if err := runWithIO(ctx, base, &okOut, &okErr); err != nil {
t.Fatalf("default TLS open-session error = %v; stderr = %s", err, okErr.String())
}
if got := strings.TrimSpace(okOut.String()); got != "tls-session" {
t.Fatalf("default TLS open-session output = %q", got)
}
// Strict: the handshake never verifies, so the connected-dial wait runs out.
// The dial error (not a flag-parse error) is what proves the flag reached the
// TLS configuration; the positive control above proves the endpoint is live.
strictCtx, strictCancel := context.WithTimeout(t.Context(), 2*time.Second)
defer strictCancel()
var strictOut, strictErr bytes.Buffer
err := runWithIO(strictCtx, append(append([]string{}, base...), "-require-certificate-validation"), &strictOut, &strictErr)
if err == nil {
t.Fatalf("strict TLS open-session succeeded against a self-signed gateway; stdout = %s", strictOut.String())
}
if !strings.Contains(err.Error(), "dial failed") {
t.Fatalf("strict TLS open-session error = %v; want a dial failure (stderr = %s)", err, strictErr.String())
}
}
+42 -1
View File
@@ -6,6 +6,8 @@ import (
"strings"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
@@ -180,7 +182,20 @@ func (e *MalformedReplyError) Error() string {
return fmt.Sprintf("mxgateway: %s returned a malformed reply: %s", e.Op, e.Detail)
}
// GatewayError wraps transport-level gRPC failures.
// ErrUnauthenticated matches, via errors.Is, a GatewayError whose gRPC status is
// Unauthenticated: the gateway did not accept the API key (missing, malformed,
// unknown, or revoked). Retrying with the same key will not help.
var ErrUnauthenticated = errors.New("mxgateway: unauthenticated")
// ErrPermissionDenied matches, via errors.Is, a GatewayError whose gRPC status
// is PermissionDenied: the API key is valid but lacks the scope the RPC needs, or
// the session belongs to another key. It is distinct from ErrUnauthenticated so a
// caller can tell "fix the key" from "grant the key a scope".
var ErrPermissionDenied = errors.New("mxgateway: permission denied")
// GatewayError wraps transport-level gRPC failures. errors.Is(err,
// ErrUnauthenticated) and errors.Is(err, ErrPermissionDenied) classify the two
// authorization failures; Code returns the raw gRPC status code.
type GatewayError struct {
// Op names the operation that failed (for example "dial" or "invoke").
Op string
@@ -207,6 +222,32 @@ func (e *GatewayError) Unwrap() error {
return e.Err
}
// Code returns the gRPC status code of the wrapped error, codes.Unknown for a
// non-gRPC error, and codes.OK for a nil receiver or nil wrapped error.
func (e *GatewayError) Code() codes.Code {
if e == nil || e.Err == nil {
return codes.OK
}
return status.Code(e.Err)
}
// Is reports whether target is the sentinel matching this error's gRPC status
// code (ErrUnauthenticated or ErrPermissionDenied). The check reads only the
// status code, never the message, so credential redaction is unaffected.
func (e *GatewayError) Is(target error) bool {
if e == nil {
return false
}
switch target {
case ErrUnauthenticated:
return e.Code() == codes.Unauthenticated
case ErrPermissionDenied:
return e.Code() == codes.PermissionDenied
default:
return false
}
}
// CommandError reports a non-OK gateway protocol status and keeps the raw
// command reply when one exists.
type CommandError struct {
+85
View File
@@ -0,0 +1,85 @@
package mxgateway
import (
"context"
"errors"
"fmt"
"strings"
"testing"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// #198 — the gateway answers a bad key with Unauthenticated and a missing scope
// with PermissionDenied; callers must be able to tell them apart with errors.Is.
func TestGatewayErrorMatchesAuthSentinelsByStatusCode(t *testing.T) {
cases := []struct {
name string
err error
unauthenticated bool
permissionDenied bool
}{
{"unauthenticated", status.Error(codes.Unauthenticated, "bad key"), true, false},
{"permission denied", status.Error(codes.PermissionDenied, "scope required"), false, true},
{"unavailable", status.Error(codes.Unavailable, "down"), false, false},
{"non-grpc", errors.New("plain"), false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := fmt.Errorf("outer: %w", &GatewayError{Op: "invoke", Err: tc.err})
if got := errors.Is(err, ErrUnauthenticated); got != tc.unauthenticated {
t.Fatalf("errors.Is(ErrUnauthenticated) = %v, want %v", got, tc.unauthenticated)
}
if got := errors.Is(err, ErrPermissionDenied); got != tc.permissionDenied {
t.Fatalf("errors.Is(ErrPermissionDenied) = %v, want %v", got, tc.permissionDenied)
}
})
}
}
func TestGatewayErrorCode(t *testing.T) {
if got := (&GatewayError{Err: status.Error(codes.PermissionDenied, "x")}).Code(); got != codes.PermissionDenied {
t.Fatalf("Code() = %v", got)
}
if got := (*GatewayError)(nil).Code(); got != codes.OK {
t.Fatalf("nil Code() = %v", got)
}
}
func TestAuthSentinelsSurviveTheWireAndRedaction(t *testing.T) {
for _, tc := range []struct {
code codes.Code
sentinel error
other error
}{
{codes.Unauthenticated, ErrUnauthenticated, ErrPermissionDenied},
{codes.PermissionDenied, ErrPermissionDenied, ErrUnauthenticated},
} {
t.Run(tc.code.String(), func(t *testing.T) {
fake := &fakeGatewayWithAlarms{acknowledgeError: status.Error(tc.code, "denied pw-secret")}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
AlarmFullReference: "Tank01.Level.HiHi",
OperatorUser: "alice",
OperatorPassword: "pw-secret",
})
if err == nil {
t.Fatal("AcknowledgeAlarm() returned no error")
}
if !errors.Is(err, tc.sentinel) {
t.Fatalf("errors.Is(%v) = false for %v", tc.sentinel, err)
}
if errors.Is(err, tc.other) {
t.Fatalf("errors.Is(%v) = true for %v", tc.other, err)
}
if strings.Contains(err.Error(), "pw-secret") {
t.Fatalf("error leaked the operator password: %v", err)
}
})
}
}
+2 -1
View File
@@ -218,11 +218,12 @@ The order matters: putting the logging scope first ensures that authentication f
## Health Checks
The shared `ZB.MOM.WW.Health` package maps three endpoints — `/healthz` (live), `/health/ready`, and
`/health/active` — and each registered check opts into a tier by tag. The gateway registers two:
`/health/active` — and each registered check opts into a tier by tag. The gateway registers three:
| Check | Endpoint tier | Fails when |
|---|---|---|
| `auth-store` | `ready` | The SQLite auth store cannot be opened. Every gRPC call authenticates against it, so its reachability genuinely gates whether the process should receive traffic. |
| `worker-executable` | `ready` | The configured `MxGateway:Worker:ExecutablePath` is not launchable — invalid path, not a `.exe`, missing, or not a PE image of `RequiredArchitecture`. It applies the launcher's own rule (`WorkerExecutableValidator.ResolveAndValidate`), so the probe and `OpenSession` cannot disagree. Every session launches the worker, so a gateway without one cannot serve MXAccess. It reports only the launch error code as entry `data` (`error_code`), never the path, because `/health/ready` is anonymous. It does not depend on session count. |
| `mxaccess-sessions` | `active` | Sessions exist and their workers have faulted. Reports `total` / `ready` / `faulted` / `starting` / `closing` as entry `data`. |
**Zero sessions is Healthy, and the tier choice follows from that.** The gateway opens an MXAccess
-5
View File
@@ -22,8 +22,6 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
"WorkingDirectory": null,
"RequiredArchitecture": "X86",
"StartupTimeoutSeconds": 30,
"StartupProbeRetryAttempts": 3,
"StartupProbeRetryDelayMilliseconds": 250,
"PipeConnectAttemptTimeoutMilliseconds": 2000,
"ShutdownTimeoutSeconds": 10,
"HeartbeatIntervalSeconds": 5,
@@ -116,8 +114,6 @@ launch CWD (SEC-01, SEC-33).
| `MxGateway:Worker:WorkingDirectory` | `null` | Optional working directory for the worker process. When set, it must be a valid filesystem path. |
| `MxGateway:Worker:RequiredArchitecture` | `X86` | Required Portable Executable architecture for the worker. Supported values are `X86` and `X64`; MXAccess parity uses `X86`. |
| `MxGateway:Worker:StartupTimeoutSeconds` | `30` | Total startup budget for process launch, startup probe, pipe connect, handshake, and worker readiness. |
| `MxGateway:Worker:StartupProbeRetryAttempts` | `3` | Number of retry attempts for transient worker startup probe failures before pipe connection and handshake continue. |
| `MxGateway:Worker:StartupProbeRetryDelayMilliseconds` | `250` | Delay between transient startup probe retry attempts. |
| `MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds` | `2000` | Per-attempt timeout used by the worker named-pipe connect retry path. The overall pipe connection still stays under the startup budget. |
| `MxGateway:Worker:WriteCompletionWaitMilliseconds` | `1500` | Bounded wait the worker holds a unary write reply (`Write`/`Write2`/`WriteSecured`/`WriteSecured2`; bulk writes excluded) for the matching MXAccess `OnWriteComplete` callback, so the reply's `statuses` carry the real commit outcome. `0` disables the wait (pure fire-and-forget replies). Must be `>= 0`. The gateway conveys the value to the worker via the `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` environment variable. Consumers that time their own writes must budget above this wait: OtOpcUa's GalaxyDriver wraps gateway writes in a 2 s Tier A resilience timeout, so a deployment raising this option past ~2000 must raise that driver `ResilienceConfig` write timeout in step or slow-but-successful commits surface as consumer-side failures. |
| `MxGateway:Worker:EventQueueCapacity` | `10000` | Capacity, in events, of the worker's outbound MXAccess event queue. Must be between `1000` and `1000000`. This is burst headroom, not a throttle: the queue has no drop policy, so filling it records a `QueueOverflow` worker fault and faults the session. Raise it for sessions whose subscription set can outrun the drain loop (large advise sets, slow event consumers); the backing queue pre-allocates its slots, so the ceiling keeps a mistyped value from committing the 32-bit worker to an outsized allocation. The gateway conveys the value to the worker via the `MXGATEWAY_EVENT_QUEUE_CAPACITY` environment variable; a missing or unusable value leaves the worker on the 10000 default rather than failing the session. |
@@ -126,7 +122,6 @@ launch CWD (SEC-01, SEC-33).
| `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. |
| `MxGateway:Worker:MaxMessageBytes` | `16842752` | Maximum worker IPC frame payload size in bytes. The validator allows values from `1024` through `268435456`, and additionally requires this to be at least `MxGateway:Protocol:MaxGrpcMessageBytes` plus a 65536-byte (64 KiB) envelope-overhead reserve **and at most that cap plus twice the reserve**. The default is the 16 MiB public gRPC cap plus one reserve. The gateway conveys the negotiated value to the worker in the handshake (`GatewayHello.max_frame_bytes`), so the worker frames to the same limit rather than a hard-coded default. The band is narrow by design, so changing either message-size knob alone no longer validates — move `MxGateway:Protocol:MaxGrpcMessageBytes` and this one together, keeping this one one to two reserves above it. |
`StartupProbeRetryAttempts`, `StartupProbeRetryDelayMilliseconds`,
`PipeConnectAttemptTimeoutMilliseconds`, timeout values, heartbeat values, and
`MaxMessageBytes` must be positive. `MaxMessageBytes` is intentionally bounded
to avoid accidental large allocations from malformed or oversized frames. It
-2
View File
@@ -940,8 +940,6 @@ Suggested configuration shape:
"WorkingDirectory": null,
"RequiredArchitecture": "X86",
"StartupTimeoutSeconds": 30,
"StartupProbeRetryAttempts": 3,
"StartupProbeRetryDelayMilliseconds": 250,
"PipeConnectAttemptTimeoutMilliseconds": 2000,
"ShutdownTimeoutSeconds": 10,
"HeartbeatIntervalSeconds": 5,
+3 -5
View File
@@ -109,7 +109,7 @@ The scalar fields mirror the counters and gauges. The four dictionaries provide
- `CommandFailuresByMethod` keys by gRPC method name.
- `EventsByFamily` keys by event family (the `Family` enum on a worker event).
- `EventsBySession` keys by `sessionId`; entries are removed via `RemoveSessionEvents` when a session closes so the map does not grow without bound.
- `RetryAttemptsByArea` keys by the resilience `area` tag, e.g. `worker_startup`.
- `RetryAttemptsByArea` keys by the resilience `area` tag passed to `RetryAttempted(area)`. No gateway code path records a retry at present (the worker launcher's startup-probe retry was removed in #188), so it is empty unless a future resilience path records one.
`EventsReceived` is read with `Interlocked.Read(ref _eventsReceived)` because `EventReceived` increments it via `Interlocked.Increment` outside the lock to keep the event-ingestion path non-blocking. `CommandsStarted`, `CommandsSucceeded`, `CommandsFailed`, and `CommandFailuresByMethod` are read the same way: the command counters run two-to-three times per gRPC call, so they are recorded with `Interlocked` and a `ConcurrentDictionary` rather than under `_syncRoot` (PERF-02). The two queue depths are pulled from their registered sources before the lock is taken, since those delegates reach into subscriber channels and worker clients.
@@ -152,15 +152,13 @@ _metrics.RemoveSessionEvents(session.SessionId);
### Worker process launcher
`Workers/WorkerProcessLauncher.cs` records process-level kills and startup retries:
`Workers/WorkerProcessLauncher.cs` records process-level kills:
```csharp
_metrics.WorkerKilled(reason);
...
_metrics.RetryAttempted("worker_startup");
```
The `worker_startup` tag is hard-coded so the `RetryAttemptsByArea` snapshot reports launcher retries distinctly from other resilience areas.
The launcher no longer records retries: its startup probe runs once (#188).
### Session worker client factory
+5 -1
View File
@@ -1011,7 +1011,11 @@ Graceful shutdown sequence:
1. Pipe reader receives `WorkerShutdown`.
2. Worker host marks shutdown requested.
3. Reject new commands.
3. Reject new commands. A command that reaches the dispatcher after intake has
stopped is answered with a `WorkerUnavailable` reply and logged as
`WorkerCommandRejectedAfterShutdown`, so the gateway's correlation wait ends
at once instead of running out its own timeout. The read loop is serial and
stops after the shutdown frame, so this guard is defensive today.
4. Let current STA command finish if within timeout.
5. Optionally run MXAccess cleanup:
- `UnAdvise`,
+9 -11
View File
@@ -49,17 +49,15 @@ Before starting the process, the launcher validates that the configured worker
path exists, has a `.exe` extension, contains a valid Windows Portable
Executable header, and matches the configured `RequiredArchitecture`.
After the process starts, `IWorkerStartupProbe` waits for startup readiness.
The default probe only verifies that the worker did not exit immediately. The
worker client replaces this probe when pipe connection, hello, and
`WorkerReady` handling are implemented.
Startup probing uses a bounded Polly retry policy. The gateway starts the worker
process once, then retries only transient startup-probe failures while the
process remains alive. The policy is configured by
`WorkerOptions.StartupProbeRetryAttempts` and
`WorkerOptions.StartupProbeRetryDelayMilliseconds`; the retry counter is
recorded as `mxgateway.retries.attempted` with `area=worker_startup`.
After the process starts, `IWorkerStartupProbe` runs once. The default probe
only verifies that the worker did not exit immediately; a worker that has
already exited cannot be recovered by waiting, so the probe is not retried.
Real readiness is the pipe connection, hello, and `WorkerReady` handshake that
`WorkerClient` performs after the launch, under the same
`WorkerOptions.StartupTimeoutSeconds` budget. (The former
`StartupProbeRetryAttempts` / `StartupProbeRetryDelayMilliseconds` options were
removed because no shipped probe could ever produce a retryable failure; a
stale value in configuration is ignored by the binder.)
The worker uses the exported pipe-attempt value inside its own bounded Polly
retry loop.
@@ -246,14 +246,6 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
options.StartupTimeoutSeconds,
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
builder);
AddIfNotPositive(
options.StartupProbeRetryAttempts,
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
builder);
AddIfNotPositive(
options.StartupProbeRetryDelayMilliseconds,
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
builder);
AddIfNotPositive(
options.PipeConnectAttemptTimeoutMilliseconds,
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
@@ -15,12 +15,6 @@ public sealed class WorkerOptions
/// <summary>The maximum time in seconds for the worker to start.</summary>
public int StartupTimeoutSeconds { get; init; } = 30;
/// <summary>The number of retry attempts for the startup probe.</summary>
public int StartupProbeRetryAttempts { get; init; } = 3;
/// <summary>The delay in milliseconds between startup probe retries.</summary>
public int StartupProbeRetryDelayMilliseconds { get; init; } = 250;
/// <summary>The timeout in milliseconds for connecting to the worker pipe.</summary>
public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000;
@@ -0,0 +1,55 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
/// <summary>
/// Readiness probe: verifies the configured worker executable is present and launchable (valid
/// path, <c>.exe</c>, on disk, PE image of the required architecture). Every <c>OpenSession</c>
/// launches it, so a gateway whose worker is missing cannot serve MXAccess at all and should not
/// receive traffic. This applies the same rule the launcher applies
/// (<see cref="WorkerExecutableValidator.ResolveAndValidate"/>), so the probe and the launch cannot
/// disagree.
/// </summary>
/// <remarks>
/// The result never carries the executable path: <c>/health/ready</c> is anonymous, so only the
/// launch error code is reported.
/// </remarks>
public sealed class WorkerExecutableHealthCheck : IHealthCheck
{
private readonly IOptions<GatewayOptions> _options;
/// <summary>Initializes a new instance of the <see cref="WorkerExecutableHealthCheck"/> class.</summary>
/// <param name="options">Gateway options carrying the worker executable settings.</param>
public WorkerExecutableHealthCheck(IOptions<GatewayOptions> options) =>
_options = options ?? throw new ArgumentNullException(nameof(options));
/// <summary>Checks the worker executable the next session launch would use.</summary>
/// <param name="context">The health check context.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Healthy when the executable is launchable; otherwise Unhealthy with the launch error code.</returns>
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
WorkerExecutableValidator.ResolveAndValidate(_options.Value.Worker);
return Task.FromResult(HealthCheckResult.Healthy("Worker executable is present."));
}
catch (WorkerProcessLaunchException exception)
{
return Task.FromResult(HealthCheckResult.Unhealthy(
$"Worker executable is not launchable ({exception.ErrorCode}).",
data: new Dictionary<string, object> { ["error_code"] = exception.ErrorCode.ToString() }));
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return Task.FromResult(HealthCheckResult.Unhealthy(
"Worker executable could not be read.",
data: new Dictionary<string, object> { ["error_code"] = exception.GetType().Name }));
}
}
}
@@ -120,6 +120,12 @@ public static class GatewayApplication
"auth-store",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready })
// Ready: every OpenSession launches the worker, so a missing worker exe means the
// gateway cannot serve MXAccess. It never depends on session count.
.AddTypeActivatedCheck<WorkerExecutableHealthCheck>(
"worker-executable",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready })
// Active, not Ready: a gateway holding no sessions is legitimately ready to serve.
// See SessionHealthCheck for why zero sessions is healthy.
.AddTypeActivatedCheck<SessionHealthCheck>(
@@ -13,6 +13,50 @@ internal static class WorkerExecutableValidator
private const int MachineOffsetFromPeHeader = PeSignatureSize;
private const int MinimumHeaderSize = 0x40;
/// <summary>
/// Resolves the configured worker executable to a full path and checks it can be launched:
/// a valid path, a <c>.exe</c> extension, present on disk, and a PE image of the required
/// architecture. Shared by the launcher and the readiness check so both apply one rule.
/// </summary>
/// <param name="options">Worker options carrying the path and required architecture.</param>
/// <returns>The full path of the validated worker executable.</returns>
/// <exception cref="WorkerProcessLaunchException">Thrown when the executable is not launchable.</exception>
public static string ResolveAndValidate(WorkerOptions options)
{
ArgumentNullException.ThrowIfNull(options);
string executablePath;
try
{
executablePath = Path.GetFullPath(options.ExecutablePath);
}
catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
{
throw new WorkerProcessLaunchException(
WorkerProcessLaunchErrorCode.InvalidExecutable,
"Worker executable path is not a valid filesystem path.",
exception);
}
if (!string.Equals(Path.GetExtension(executablePath), ".exe", StringComparison.OrdinalIgnoreCase))
{
throw new WorkerProcessLaunchException(
WorkerProcessLaunchErrorCode.InvalidExecutable,
"Worker executable path must point to a .exe file.");
}
if (!File.Exists(executablePath))
{
throw new WorkerProcessLaunchException(
WorkerProcessLaunchErrorCode.ExecutableNotFound,
"Worker executable does not exist.");
}
Validate(executablePath, options.RequiredArchitecture);
return executablePath;
}
/// <summary>Validates that a worker executable file has the required architecture.</summary>
/// <param name="executablePath">Full path to the worker executable file.</param>
/// <param name="requiredArchitecture">Required CPU architecture (x86 or x64).</param>
@@ -5,8 +5,6 @@ using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using Polly;
using Polly.Retry;
namespace ZB.MOM.WW.MxGateway.Server.Workers;
@@ -140,15 +138,11 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
using CancellationTokenSource startupTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
startupTimeout.CancelAfter(TimeSpan.FromSeconds(_workerOptions.StartupTimeoutSeconds));
await CreateStartupProbePipeline(process)
.ExecuteAsync(
async token =>
{
// The probe runs once. Real readiness is the WorkerReady handshake WorkerClient performs
// after launch, under the same StartupTimeoutSeconds budget; the probe only catches a
// worker that exited immediately, which no retry can recover (#188).
await _startupProbe
.WaitUntilReadyAsync(process, request, token)
.ConfigureAwait(false);
},
startupTimeout.Token)
.WaitUntilReadyAsync(process, request, startupTimeout.Token)
.ConfigureAwait(false);
return new WorkerProcessHandle(process, commandLine, startedAt);
@@ -230,39 +224,8 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
return startInfo;
}
private string ResolveExecutablePath()
{
string executablePath;
try
{
executablePath = Path.GetFullPath(_workerOptions.ExecutablePath);
}
catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
{
throw new WorkerProcessLaunchException(
WorkerProcessLaunchErrorCode.InvalidExecutable,
"Worker executable path is not a valid filesystem path.",
exception);
}
if (!string.Equals(Path.GetExtension(executablePath), ".exe", StringComparison.OrdinalIgnoreCase))
{
throw new WorkerProcessLaunchException(
WorkerProcessLaunchErrorCode.InvalidExecutable,
"Worker executable path must point to a .exe file.");
}
if (!File.Exists(executablePath))
{
throw new WorkerProcessLaunchException(
WorkerProcessLaunchErrorCode.ExecutableNotFound,
"Worker executable does not exist.");
}
WorkerExecutableValidator.Validate(executablePath, _workerOptions.RequiredArchitecture);
return executablePath;
}
private string ResolveExecutablePath() =>
WorkerExecutableValidator.ResolveAndValidate(_workerOptions);
private string ResolveWorkingDirectory(string executablePath)
{
@@ -310,43 +273,6 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
}
}
private ResiliencePipeline CreateStartupProbePipeline(IWorkerProcess process)
{
RetryStrategyOptions retryOptions = new()
{
MaxRetryAttempts = Math.Max(0, _workerOptions.StartupProbeRetryAttempts - 1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(_workerOptions.StartupProbeRetryDelayMilliseconds),
MaxDelay = TimeSpan.FromSeconds(2),
ShouldHandle = new PredicateBuilder().Handle<Exception>(exception =>
ShouldRetryStartupProbe(exception, process)),
OnRetry = args =>
{
_metrics.RetryAttempted("worker_startup");
_logger.LogDebug(
args.Outcome.Exception,
"Retrying worker startup probe after transient failure. Attempt {Attempt}.",
args.AttemptNumber + 1);
return default;
},
};
return new ResiliencePipelineBuilder()
.AddRetry(retryOptions)
.Build();
}
private static bool ShouldRetryStartupProbe(Exception exception, IWorkerProcess process)
{
if (exception is OperationCanceledException or WorkerProcessLaunchException)
{
return false;
}
return !process.HasExited;
}
private static void ValidateRequest(WorkerProcessLaunchRequest request)
{
if (string.IsNullOrWhiteSpace(request.SessionId))
@@ -35,7 +35,6 @@
<!-- Security pin: GHSA-846f-f764-76mx / GHSA-2fmv-rv2c-59x7. Microsoft.Data.SqlClient 6.0.2
pulls the vulnerable System.Security.Cryptography.Pkcs 9.0.4; 10.0.13 patches it. -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
<PackageReference Include="Polly.Core" Version="8.6.6" />
</ItemGroup>
<ItemGroup>
@@ -38,8 +38,6 @@ public sealed class GatewayOptionsTests
Assert.Equal(@"src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe", options.Worker.ExecutablePath);
Assert.Equal(WorkerArchitecture.X86, options.Worker.RequiredArchitecture);
Assert.Equal(30, options.Worker.StartupTimeoutSeconds);
Assert.Equal(3, options.Worker.StartupProbeRetryAttempts);
Assert.Equal(250, options.Worker.StartupProbeRetryDelayMilliseconds);
Assert.Equal(2000, options.Worker.PipeConnectAttemptTimeoutMilliseconds);
Assert.Equal(1500, options.Worker.WriteCompletionWaitMilliseconds);
Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds);
@@ -110,7 +108,6 @@ public sealed class GatewayOptionsTests
/// <param name="expectedFailure">Expected validation error message.</param>
[Theory]
[InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
[InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")]
[InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")]
[InlineData("MxGateway:Worker:WriteCompletionWaitMilliseconds", "-1", "MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.")]
[InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")]
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Health;
using ZB.MOM.WW.MxGateway.Server;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
/// <summary>Readiness coverage for the worker executable (#206).</summary>
public sealed class WorkerExecutableHealthCheckTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"mxgw-workercheck-{Guid.NewGuid():N}");
/// <summary>Initializes a new instance of the <see cref="WorkerExecutableHealthCheckTests"/> class.</summary>
public WorkerExecutableHealthCheckTests() => Directory.CreateDirectory(_directory);
/// <inheritdoc />
public void Dispose() => Directory.Delete(_directory, recursive: true);
/// <summary>A present x86 worker image is healthy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Healthy_WhenWorkerExecutableIsLaunchable()
{
string path = WritePeStub(machine: 0x014c);
HealthCheckResult result = await CheckAsync(path, WorkerArchitecture.X86);
Assert.Equal(HealthStatus.Healthy, result.Status);
}
/// <summary>A missing worker executable is unhealthy and reports only the error code, never the path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Unhealthy_WhenWorkerExecutableIsMissing()
{
string path = Path.Combine(_directory, "missing", "ZB.MOM.WW.MxGateway.Worker.exe");
HealthCheckResult result = await CheckAsync(path, WorkerArchitecture.X86);
Assert.Equal(HealthStatus.Unhealthy, result.Status);
Assert.Equal("ExecutableNotFound", result.Data["error_code"]);
Assert.DoesNotContain(_directory, result.Description, StringComparison.OrdinalIgnoreCase);
}
/// <summary>A worker image of the wrong architecture is unhealthy, as the launcher would reject it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Unhealthy_WhenWorkerExecutableHasWrongArchitecture()
{
string path = WritePeStub(machine: 0x8664);
HealthCheckResult result = await CheckAsync(path, WorkerArchitecture.X86);
Assert.Equal(HealthStatus.Unhealthy, result.Status);
Assert.Equal("InvalidExecutable", result.Data["error_code"]);
}
/// <summary>
/// The gateway registers the check on the ready tier, and the session check stays on the active
/// tier so zero sessions keeps readiness green.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Build_RegistersWorkerExecutableCheckOnReadyTier()
{
await using WebApplication app = GatewayApplication.Build([]);
HealthCheckServiceOptions options =
app.Services.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value;
HealthCheckRegistration worker = Assert.Single(options.Registrations, r => r.Name == "worker-executable");
Assert.Contains(ZbHealthTags.Ready, worker.Tags);
HealthCheckRegistration sessions = Assert.Single(options.Registrations, r => r.Name == "mxaccess-sessions");
Assert.DoesNotContain(ZbHealthTags.Ready, sessions.Tags);
}
private static Task<HealthCheckResult> CheckAsync(string path, WorkerArchitecture architecture)
{
WorkerExecutableHealthCheck check = new(Options.Create(new GatewayOptions
{
Worker = new WorkerOptions
{
ExecutablePath = path,
RequiredArchitecture = architecture,
},
}));
return check.CheckHealthAsync(new HealthCheckContext());
}
private string WritePeStub(ushort machine)
{
string path = Path.Combine(_directory, "ZB.MOM.WW.MxGateway.Worker.exe");
byte[] bytes = new byte[0x100];
bytes[0] = (byte)'M';
bytes[1] = (byte)'Z';
BitConverter.GetBytes(0x80).CopyTo(bytes, 0x3c);
bytes[0x80] = (byte)'P';
bytes[0x81] = (byte)'E';
BitConverter.GetBytes(machine).CopyTo(bytes, 0x84);
File.WriteAllBytes(path, bytes);
return path;
}
}
@@ -167,32 +167,48 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(1, metrics.GetSnapshot().WorkerKills);
}
/// <summary>Verifies that transient startup probe failures are retried without respawning the worker process.</summary>
/// <summary>
/// The startup probe runs once. A probe failure fails the launch — there is no retry to
/// configure, because real readiness is the WorkerReady handshake that follows the launch
/// (#188).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LaunchAsync_WhenStartupProbeFailsTransiently_RetriesWithoutRespawningWorker()
public async Task LaunchAsync_WhenStartupProbeFails_DoesNotRetryTheProbe()
{
using TestDirectory directory = TestDirectory.Create();
string executablePath = directory.CreateWorkerExecutable(machine: 0x014c);
FakeWorkerProcess process = new(processId: 1234);
FakeWorkerProcessFactory processFactory = new(process);
GatewayMetrics metrics = new();
TransientStartupProbe probe = new(failuresBeforeSuccess: 1);
WorkerProcessLauncher launcher = CreateLauncher(
executablePath,
processFactory,
new TransientStartupProbe(failuresBeforeSuccess: 1),
metrics,
startupProbeRetryAttempts: 2,
startupProbeRetryDelayMilliseconds: 1);
probe,
metrics);
using WorkerProcessHandle handle = await launcher.LaunchAsync(CreateRequest());
WorkerProcessLaunchException exception =
await Assert.ThrowsAsync<WorkerProcessLaunchException>(
async () => await launcher.LaunchAsync(CreateRequest()));
Assert.Same(process, handle.Process);
Assert.Equal(WorkerProcessLaunchErrorCode.StartupFailed, exception.ErrorCode);
Assert.Equal(1, probe.Attempts);
Assert.Equal(1, processFactory.StartCount);
Assert.False(process.KillCalled);
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(1, snapshot.RetryAttempts);
Assert.Equal(1, snapshot.RetryAttemptsByArea["worker_startup"]);
Assert.True(process.KillCalled);
Assert.Equal(0, metrics.GetSnapshot().RetryAttempts);
}
/// <summary>
/// The retry options are gone from <see cref="WorkerOptions"/> so configuration cannot
/// promise a startup-probe retry that never happens (#188).
/// </summary>
[Fact]
public void WorkerOptions_HasNoStartupProbeRetryOptions()
{
Assert.DoesNotContain(
typeof(WorkerOptions).GetProperties(),
property => property.Name.StartsWith("StartupProbeRetry", StringComparison.Ordinal));
}
/// <summary>Verifies that a startup probe timeout kills and disposes the worker process.</summary>
@@ -290,8 +306,6 @@ public sealed class WorkerProcessLauncherTests
IWorkerStartupProbe startupProbe,
GatewayMetrics? metrics = null,
int startupTimeoutSeconds = 30,
int startupProbeRetryAttempts = 3,
int startupProbeRetryDelayMilliseconds = 250,
int eventQueueCapacity = 10000,
string ackClientName = "MxGateway")
{
@@ -303,8 +317,6 @@ public sealed class WorkerProcessLauncherTests
ExecutablePath = executablePath,
RequiredArchitecture = WorkerArchitecture.X86,
StartupTimeoutSeconds = startupTimeoutSeconds,
StartupProbeRetryAttempts = startupProbeRetryAttempts,
StartupProbeRetryDelayMilliseconds = startupProbeRetryDelayMilliseconds,
EventQueueCapacity = eventQueueCapacity,
},
};
@@ -388,6 +400,9 @@ public sealed class WorkerProcessLauncherTests
{
private int _attempts;
/// <summary>Gets the number of times the probe was invoked.</summary>
public int Attempts => Volatile.Read(ref _attempts);
/// <inheritdoc />
public Task WaitUntilReadyAsync(
IWorkerProcess process,
@@ -1943,6 +1943,60 @@ public sealed class WorkerPipeSessionTests
&& (string?)correlationId == "command-dropped-after-shutdown");
}
/// <summary>
/// A command that reaches the dispatcher after shutdown has stopped command intake must be
/// answered with <see cref="ProtocolStatusCode.WorkerUnavailable"/> (and logged) rather than
/// dropped silently, which would leave the gateway's correlation wait to run out its own
/// timeout with no trace (#182).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CommandAfterShutdown_RepliesWorkerUnavailable()
{
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10));
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
FakeRuntimeSession runtime = new();
RecordingWorkerLogger logger = new();
WorkerFrameProtocolOptions options = CreateOptions();
WorkerPipeSession session = new(
new WorkerFrameReader(pipePair.WorkerStream, options),
new WorkerFrameWriter(pipePair.WorkerStream, options),
options,
() => 1234,
new WorkerPipeSessionOptions
{
HeartbeatInterval = TimeSpan.FromSeconds(1),
HeartbeatGrace = TimeSpan.FromSeconds(5),
},
() => runtime,
logger);
Task runTask = session.RunAsync(cancellation.Token);
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
session.StopAcceptingCommands();
await pipePair.GatewayWriter.WriteAsync(
CreateCommandEnvelope("command-after-shutdown"),
cancellation.Token);
WorkerEnvelope reply = await ReadUntilAsync(
pipePair.GatewayReader,
WorkerEnvelope.BodyOneofCase.WorkerCommandReply,
envelope => envelope.CorrelationId == "command-after-shutdown",
cancellation.Token);
Assert.Equal(ProtocolStatusCode.WorkerUnavailable, reply.WorkerCommandReply.Reply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.Register, reply.WorkerCommandReply.Reply.Kind);
Assert.Equal(SessionId, reply.WorkerCommandReply.Reply.SessionId);
Assert.False(runtime.DispatchStarted.IsSet);
Assert.Contains(
logger.Events,
entry => entry.EventName == "WorkerCommandRejectedAfterShutdown"
&& entry.Fields.TryGetValue("correlation_id", out object? correlationId)
&& (string?)correlationId == "command-after-shutdown");
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
}
private static WorkerPipeSession CreateSession(
Stream inbound,
Stream outbound,
@@ -748,7 +748,11 @@ public sealed class WorkerPipeSession
return await HandleControlCommandAsync(envelope, cancellationToken).ConfigureAwait(false);
}
TryStartCommandTask(envelope, cancellationToken);
if (!TryStartCommandTask(envelope, cancellationToken))
{
await RejectCommandAfterShutdownAsync(envelope, cancellationToken).ConfigureAwait(false);
}
return true;
case WorkerEnvelope.BodyOneofCase.WorkerShutdown:
await ShutdownAsync(envelope.WorkerShutdown, cancellationToken).ConfigureAwait(false);
@@ -1230,7 +1234,12 @@ public sealed class WorkerPipeSession
}
}
private void TryStartCommandTask(
/// <summary>
/// Starts the command on the runtime session unless shutdown has already stopped command
/// intake. Returns <c>false</c> when the command was refused, so the caller can answer the
/// correlation instead of leaving the gateway's wait to run out its own timeout.
/// </summary>
private bool TryStartCommandTask(
WorkerEnvelope envelope,
CancellationToken cancellationToken)
{
@@ -1239,7 +1248,7 @@ public sealed class WorkerPipeSession
{
if (!_acceptingCommands)
{
return;
return false;
}
commandTask = ProcessCommandAsync(envelope, cancellationToken);
@@ -1254,6 +1263,60 @@ public sealed class WorkerPipeSession
// would fault the discarded task with no observer. Close the gap the same way the rest of
// the file does.
ObserveFaultWhenever(ObserveCommandTaskAsync(commandTask));
return true;
}
/// <summary>
/// Answers a command that arrived after shutdown stopped command intake with a
/// <see cref="ProtocolStatusCode.WorkerUnavailable"/> reply, and logs it. The worker is
/// going away, so the reply is best effort: if the pipe is already unusable the failure is
/// logged and the gateway falls back to its own correlation timeout, as it did before.
/// </summary>
private async Task RejectCommandAfterShutdownAsync(
WorkerEnvelope envelope,
CancellationToken cancellationToken)
{
MxCommandKind kind = envelope.WorkerCommand?.Command?.Kind ?? MxCommandKind.Unspecified;
const string message = "The worker is shutting down and no longer accepts commands.";
_logger?.Information(
"WorkerCommandRejectedAfterShutdown",
new Dictionary<string, object?>
{
["correlation_id"] = envelope.CorrelationId,
["command_kind"] = kind.ToString(),
["worker_state"] = _state.ToString(),
});
MxCommandReply reply = new()
{
SessionId = _options.SessionId,
CorrelationId = envelope.CorrelationId,
Kind = kind,
DiagnosticMessage = message,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.WorkerUnavailable,
Message = message,
},
};
try
{
await WriteControlReplyAsync(reply, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is IOException
|| exception is ObjectDisposedException
|| exception is WorkerFrameProtocolException)
{
_logger?.Error(
"WorkerCommandRejectionReplyFailed",
new Dictionary<string, object?>
{
["correlation_id"] = envelope.CorrelationId,
["command_kind"] = kind.ToString(),
["reason"] = exception.Message,
});
}
}
private async Task ObserveCommandTaskAsync(Task commandTask)
@@ -1274,7 +1337,12 @@ public sealed class WorkerPipeSession
}
}
private void StopAcceptingCommands()
/// <summary>
/// Stops command intake. Internal so tests can reach the refusal path directly: the read
/// loop is serial and ends after a shutdown, so a command cannot race it over the pipe today,
/// but the guard must still answer if a future change lets one through.
/// </summary>
internal void StopAcceptingCommands()
{
lock (_commandTaskGate)
{