fix(host): log a routine service stop as Information, not [FTL] (ScadaBridge#76)

An OperationCanceledException escaping RunAsync after ApplicationStopping has fired
(WindowsServiceLifetime.StopAsync gets a cancelled token after shutdown finished) is a
normal stop. Program.IsGracefulShutdown now routes it to an Information line; any other
exception, or a cancellation with no shutdown requested, still logs Fatal and rethrows.
This commit is contained in:
Joseph Doherty
2026-11-09 12:05:20 -05:00
parent a5c3de63e9
commit e0ec2a0507
2 changed files with 65 additions and 0 deletions
+26
View File
@@ -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
/// </summary>
public partial class Program
{
/// <summary>
/// True when <paramref name="ex"/> is the cancellation a routine host stop produces: an
/// <see cref="OperationCanceledException"/> (incl. <see cref="TaskCanceledException"/>) escaping
/// <c>RunAsync</c> while the host's <c>ApplicationStopping</c> 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 <c>[FTL]</c>.
/// </summary>
/// <param name="ex">The exception that escaped the host run.</param>
/// <param name="hostStopping">The host's <c>ApplicationStopping</c> token (default if the host never built).</param>
/// <returns>True when the exception is the expected cancellation of a requested shutdown.</returns>
internal static bool IsGracefulShutdown(Exception ex, CancellationToken hostStopping) =>
ex is OperationCanceledException && hostStopping.IsCancellationRequested;
/// <summary>
/// Extracts the distinct TCP ports from an ASP.NET Core server-URLs string (the
/// <c>ASPNETCORE_URLS</c> / <c>--urls</c> value, e.g. <c>"http://+:5000"</c> or a