diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Health/RequiredSingletonsHealthCheck.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Health/RequiredSingletonsHealthCheck.cs index b3e84441..5fb5c894 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Health/RequiredSingletonsHealthCheck.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Health/RequiredSingletonsHealthCheck.cs @@ -157,11 +157,9 @@ public sealed class RequiredSingletonsHealthCheck : IHealthCheck // ActorSelection so a missing path resolves an ActorIdentity with a null // Subject (rather than throwing) within the bounded timeout. var selection = system.ActorSelection($"/user/{proxyName}"); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(ProbeTimeout); var identity = await selection - .Ask(new Identify(proxyName), ProbeTimeout, cts.Token) + .Ask(new Identify(proxyName), ProbeTimeout, cancellationToken) .ConfigureAwait(false); return (proxyName, identity.Subject is not null); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/RequiredSingletonsHealthCheckTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/RequiredSingletonsHealthCheckTests.cs index fd6c281d..61e87938 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/RequiredSingletonsHealthCheckTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/RequiredSingletonsHealthCheckTests.cs @@ -27,6 +27,10 @@ public class RequiredSingletonsHealthCheckTests : TestKit { /// A minimal live actor that does nothing — its mere existence makes /// an resolve a non-null Subject (i.e. "reachable"). + /// No Receive<Identify> handler is needed: Akka's + /// answers every message with + /// an automatically, so an empty actor at the proxy + /// path is sufficient to simulate a reachable singleton. private sealed class EchoActor : ReceiveActor { } @@ -106,4 +110,34 @@ public class RequiredSingletonsHealthCheckTests : TestKit Assert.Equal(HealthStatus.Unhealthy, result.Status); } + + [Fact] + public async Task PreCancelledToken_ReportsUnhealthy_DoesNotThrow() + { + // Shutdown-race path: CheckHealthAsync is called with an already-cancelled + // token (e.g. host is tearing down). The check must never throw — any + // OperationCanceledException from Ask must be caught and mapped to Unhealthy. + foreach (var name in RequiredSingletonsHealthCheck.RequiredSingletonProxyNames) + { + Sys.ActorOf(Props.Create(() => new EchoActor()), name); + } + + var check = new RequiredSingletonsHealthCheck( + ProviderReturning(Sys), + NullLogger.Instance); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); // already cancelled before the check runs + + var context = new HealthCheckContext + { + Registration = new HealthCheckRegistration( + "required-singletons", check, failureStatus: null, tags: null), + }; + + // Must not throw; an already-cancelled token → all probes fail → Unhealthy. + var result = await check.CheckHealthAsync(context, cts.Token); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } }