diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index a5dd3e30..63da889c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -151,6 +151,12 @@ Log.Logger = ZB.MOM.WW.ScadaBridge.Host.LoggerConfigurationFactory .Build(configuration, nodeRole, siteId, nodeHostname) .CreateLogger(); +// The host's ApplicationStopping token, captured once the host is built. A normal Windows service +// stop (or SIGTERM) cancels it BEFORE RunAsync unwinds, and WindowsServiceLifetime.StopAsync can +// then surface an OperationCanceledException out of RunAsync after shutdown has already finished. +// The catch below uses it to tell that routine stop from a genuine fatal exit (ScadaBridge#76). +var hostStopping = CancellationToken.None; + try { Log.Information("Starting ScadaBridge host as {Role} on {Hostname}", nodeRole, nodeHostname); @@ -639,6 +645,7 @@ try builder.Configuration.GetSection(ZB.MOM.WW.ScadaBridge.DeploymentManager.ServiceCollectionExtensions.OptionsSection)); var app = builder.Build(); + hostStopping = app.Lifetime.ApplicationStopping; // Apply or validate database migrations (skip when running in test harness) if (!string.Equals(configuration["ScadaBridge:Database:SkipMigrations"], "true", StringComparison.OrdinalIgnoreCase)) @@ -832,6 +839,7 @@ try SiteServiceRegistration.Configure(builder.Services, builder.Configuration); var app = builder.Build(); + hostStopping = app.Lifetime.ApplicationStopping; // Endpoint routing middleware. The gRPC service mapping below and the // /metrics scrape endpoint both run on endpoint routing, so UseRouting() @@ -890,6 +898,11 @@ try throw new InvalidOperationException($"Unknown role: {nodeRole}. Must be 'Central' or 'Site'."); } } +catch (Exception ex) when (Program.IsGracefulShutdown(ex, hostStopping)) +{ + // Routine stop: shutdown was requested and the host unwound with a cancellation. Not a fatal exit. + Log.Information("ScadaBridge host stopped (shutdown requested)"); +} catch (Exception ex) { Log.Fatal(ex, "ScadaBridge host terminated unexpectedly"); @@ -905,6 +918,19 @@ finally /// public partial class Program { + /// + /// True when is the cancellation a routine host stop produces: an + /// (incl. ) escaping + /// RunAsync while the host's ApplicationStopping token is already cancelled + /// (ScadaBridge#76). Anything else - a non-cancellation exception, or a cancellation with no + /// shutdown requested - is still a genuine fatal exit and must keep logging [FTL]. + /// + /// The exception that escaped the host run. + /// The host's ApplicationStopping token (default if the host never built). + /// True when the exception is the expected cancellation of a requested shutdown. + internal static bool IsGracefulShutdown(Exception ex, CancellationToken hostStopping) => + ex is OperationCanceledException && hostStopping.IsCancellationRequested; + /// /// Extracts the distinct TCP ports from an ASP.NET Core server-URLs string (the /// ASPNETCORE_URLS / --urls value, e.g. "http://+:5000" or a diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GracefulShutdownTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GracefulShutdownTests.cs new file mode 100644 index 00000000..67474d4f --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GracefulShutdownTests.cs @@ -0,0 +1,39 @@ +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// ScadaBridge#76: a routine Windows service stop must not be logged as +/// [FTL] host terminated unexpectedly. +/// +public class GracefulShutdownTests +{ + /// A cancellation escaping RunAsync after ApplicationStopping fired is a routine stop. + [Fact] + public void Cancellation_AfterStopRequested_IsGraceful() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.True(Program.IsGracefulShutdown(new OperationCanceledException(), cts.Token)); + Assert.True(Program.IsGracefulShutdown(new TaskCanceledException(), cts.Token)); + } + + /// A cancellation with no shutdown requested is still a genuine failure. + [Fact] + public void Cancellation_WithoutStopRequested_IsNotGraceful() + { + using var cts = new CancellationTokenSource(); + + Assert.False(Program.IsGracefulShutdown(new OperationCanceledException(), cts.Token)); + Assert.False(Program.IsGracefulShutdown(new OperationCanceledException(), CancellationToken.None)); + } + + /// A real fault during shutdown must still be reported as fatal. + [Fact] + public void NonCancellationException_IsNeverGraceful() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.False(Program.IsGracefulShutdown(new InvalidOperationException("boom"), cts.Token)); + } +}