Compare commits
6 Commits
main
...
fix/p2-batch
| Author | SHA1 | Date | |
|---|---|---|---|
| 61c00d59a9 | |||
| 574369b666 | |||
| 9351ec1d2c | |||
| b9096f7c57 | |||
| ff397b27be | |||
| 3a3d9e8e06 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user