diff --git a/docs/Diagnostics.md b/docs/Diagnostics.md
index b3252a7..540bdde 100644
--- a/docs/Diagnostics.md
+++ b/docs/Diagnostics.md
@@ -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
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/WorkerExecutableHealthCheck.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/WorkerExecutableHealthCheck.cs
new file mode 100644
index 0000000..da1a6e3
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/WorkerExecutableHealthCheck.cs
@@ -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;
+
+///
+/// Readiness probe: verifies the configured worker executable is present and launchable (valid
+/// path, .exe, on disk, PE image of the required architecture). Every OpenSession
+/// 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
+/// (), so the probe and the launch cannot
+/// disagree.
+///
+///
+/// The result never carries the executable path: /health/ready is anonymous, so only the
+/// launch error code is reported.
+///
+public sealed class WorkerExecutableHealthCheck : IHealthCheck
+{
+ private readonly IOptions _options;
+
+ /// Initializes a new instance of the class.
+ /// Gateway options carrying the worker executable settings.
+ public WorkerExecutableHealthCheck(IOptions options) =>
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+
+ /// Checks the worker executable the next session launch would use.
+ /// The health check context.
+ /// Token to cancel the asynchronous operation.
+ /// Healthy when the executable is launchable; otherwise Unhealthy with the launch error code.
+ public Task 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 { ["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 { ["error_code"] = exception.GetType().Name }));
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs
index d80f859..e86a74b 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs
@@ -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(
+ "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(
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerExecutableValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerExecutableValidator.cs
index 5a9a9af..815f77d 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerExecutableValidator.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerExecutableValidator.cs
@@ -13,6 +13,50 @@ internal static class WorkerExecutableValidator
private const int MachineOffsetFromPeHeader = PeSignatureSize;
private const int MinimumHeaderSize = 0x40;
+ ///
+ /// Resolves the configured worker executable to a full path and checks it can be launched:
+ /// a valid path, a .exe 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.
+ ///
+ /// Worker options carrying the path and required architecture.
+ /// The full path of the validated worker executable.
+ /// Thrown when the executable is not launchable.
+ 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;
+ }
+
/// Validates that a worker executable file has the required architecture.
/// Full path to the worker executable file.
/// Required CPU architecture (x86 or x64).
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs
index 178d9d7..d25ae53 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs
@@ -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)
{
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/WorkerExecutableHealthCheckTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/WorkerExecutableHealthCheckTests.cs
new file mode 100644
index 0000000..db26b12
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/WorkerExecutableHealthCheckTests.cs
@@ -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;
+
+/// Readiness coverage for the worker executable (#206).
+public sealed class WorkerExecutableHealthCheckTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), $"mxgw-workercheck-{Guid.NewGuid():N}");
+
+ /// Initializes a new instance of the class.
+ public WorkerExecutableHealthCheckTests() => Directory.CreateDirectory(_directory);
+
+ ///
+ public void Dispose() => Directory.Delete(_directory, recursive: true);
+
+ /// A present x86 worker image is healthy.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Healthy_WhenWorkerExecutableIsLaunchable()
+ {
+ string path = WritePeStub(machine: 0x014c);
+
+ HealthCheckResult result = await CheckAsync(path, WorkerArchitecture.X86);
+
+ Assert.Equal(HealthStatus.Healthy, result.Status);
+ }
+
+ /// A missing worker executable is unhealthy and reports only the error code, never the path.
+ /// A task that represents the asynchronous operation.
+ [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);
+ }
+
+ /// A worker image of the wrong architecture is unhealthy, as the launcher would reject it.
+ /// A task that represents the asynchronous operation.
+ [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"]);
+ }
+
+ ///
+ /// The gateway registers the check on the ready tier, and the session check stays on the active
+ /// tier so zero sessions keeps readiness green.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Build_RegistersWorkerExecutableCheckOnReadyTier()
+ {
+ await using WebApplication app = GatewayApplication.Build([]);
+ HealthCheckServiceOptions options =
+ app.Services.GetRequiredService>().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 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;
+ }
+}