fix(host): fatal-log, flush and exit non-zero when the host fails to start (#618)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 06:52:54 -04:00
parent 3baa332d6b
commit 413bc5c04f
5 changed files with 94 additions and 1 deletions
@@ -0,0 +1,47 @@
using System.Diagnostics;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Startup;
/// <summary>
/// #618 — a host that fails to start must END the process with a non-zero exit code and a Fatal line,
/// not linger as a half-started zombie. Spawns the built host assembly as a real child process.
/// </summary>
public sealed class FailStartExitTests
{
[Fact]
public async Task A_failed_start_exits_non_zero_and_logs_fatal()
{
var hostDll = typeof(ZB.MOM.WW.OtOpcUa.Host.Program).Assembly.Location;
var dir = Path.GetDirectoryName(hostDll)!;
var psi = new ProcessStartInfo("dotnet", $"\"{hostDll}\"")
{
WorkingDirectory = dir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
// An invalid Akka seed address makes the cluster wiring throw before the host can ever run;
// no external service is touched.
psi.Environment["Cluster__Roles"] = "driver";
psi.Environment["Cluster__SeedNodes__0"] = "not-a-valid-address";
using var proc = Process.Start(psi)!;
var stdout = proc.StandardOutput.ReadToEndAsync();
var stderr = proc.StandardError.ReadToEndAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
try { await proc.WaitForExitAsync(cts.Token); }
catch (OperationCanceledException)
{
proc.Kill(entireProcessTree: true);
Assert.Fail("host did not exit within 60 s of a failed start (#618): " + await stdout + await stderr);
}
var output = await stdout + await stderr;
proc.ExitCode.ShouldNotBe(0, output);
output.ShouldContain("terminated during startup", Case.Insensitive, "the failure must be logged as Fatal");
}
}