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).
This commit is contained in:
Joseph Doherty
2026-11-06 16:13:09 -05:00
parent 574369b666
commit 61c00d59a9
6 changed files with 215 additions and 34 deletions
+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
@@ -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>
@@ -224,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)
{
@@ -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;
}
}