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));
+ }
+}