using System.Diagnostics; using System.Net; using Akka.Actor; using Akka.Cluster; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Xunit.Abstractions; using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Host; using ZB.MOM.WW.ScadaBridge.Host.Actors; using AkkaCluster = Akka.Cluster.Cluster; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// /// ScadaBridge#140 (the ScadaBridge half of lmxopcua#906): a graceful stop of a site node whose /// peer holds an open LocalDb sync stream must leave the Akka cluster FIRST and finish promptly, /// not wait out Kestrel's graceful-drain timeout. /// /// /// /// The stopping node is a real — the hosting model whose hosted-service /// order caused the bug (the web server stops before the Akka service) — with a real Kestrel h2c /// listener, the real MapZbLocalDbSync behind the real , /// the production forming a real single-member Akka cluster, and the /// production . The peer is a real LocalDb /// SyncBackgroundService dialling it over loopback and holding its stream open. /// /// /// The web server's is wrapped to timestamp the moment its StopAsync (the /// drain) begins, so "left the cluster before the drain" is asserted as an ORDER, not inferred from /// a total duration. /// /// /// The second test is the positive control: the same stack as it was before the fix — the Akka service /// seen by the host as a plain (so its shutdown runs only in /// StopAsync), no stream interceptor, and the LocalDb sync service built without the host /// lifetime (LocalDb 0.3.8 would otherwise end the stream itself) — reproduces the bug: the stop waits out the whole /// shutdown timeout and the cluster is left only after the drain. Without it, a fast stop in the first /// test could be a harness that never held a stream open at all. /// /// [Collection("LocalDbSitePairConvergence")] public sealed class GracefulStopWithOpenSyncStreamTests { private const string ApiKey = "scadabridge-140-graceful-stop-key"; /// The host's ShutdownTimeout in these tests — Kestrel's drain budget (30 s in production). private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(8); private readonly ITestOutputHelper _output; static GracefulStopWithOpenSyncStreamTests() => AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); /// Creates the test class. /// xunit output, for the measured timings. public GracefulStopWithOpenSyncStreamTests(ITestOutputHelper output) => _output = output; /// With the fix: the node leaves the cluster before the drain begins and stops well inside the budget. /// A task that represents the asynchronous operation. [Fact] public async Task GracefulStop_WithOpenSyncStream_LeavesClusterBeforeDrain_AndFinishesPromptly() { var result = await RunStopScenarioAsync(withFix: true); // The peer's stream was open and was ended by the stop (not by a peer that never connected). Assert.True(result.StreamWasOpen, "the peer's sync stream was never open on the stopping node"); // Left the cluster, and did so before the web server began draining. Assert.NotNull(result.MemberRemovedAt); Assert.NotNull(result.ServerStopBeganAt); Assert.True(result.MemberRemovedAt < result.ServerStopBeganAt, $"the node must leave the cluster BEFORE Kestrel drains (removed at {result.MemberRemovedAt?.TotalMilliseconds:0} ms, " + $"drain began at {result.ServerStopBeganAt?.TotalMilliseconds:0} ms)"); // Finished well inside the drain budget: the open sync stream did not hold the stop. Assert.True(result.StopElapsed < TimeSpan.FromSeconds(4), $"stop took {result.StopElapsed.TotalMilliseconds:0} ms against a {ShutdownTimeout.TotalSeconds:0} s drain budget"); // A planned stop is not a self-down: no exit code 75. Assert.False(result.TerminatedOutsideHostShutdown); } /// Positive control: the pre-fix wiring reproduces the late leave and the full-budget stop. /// A task that represents the asynchronous operation. [Fact] public async Task PositiveControl_WithoutTheFix_StopWaitsOutTheDrain_AndLeavesTheClusterLast() { var result = await RunStopScenarioAsync(withFix: false); Assert.True(result.StreamWasOpen); Assert.True(result.StopElapsed >= ShutdownTimeout - TimeSpan.FromSeconds(1), $"stop took {result.StopElapsed.TotalMilliseconds:0} ms; expected it to wait out the {ShutdownTimeout.TotalSeconds:0} s drain"); Assert.NotNull(result.MemberRemovedAt); Assert.True(result.MemberRemovedAt > result.ServerStopBeganAt); Assert.True(result.MemberRemovedAt >= ShutdownTimeout - TimeSpan.FromSeconds(1)); Assert.False(result.TerminatedOutsideHostShutdown); } private sealed record StopResult( TimeSpan StopElapsed, TimeSpan? MemberRemovedAt, TimeSpan? ServerStopBeganAt, bool StreamWasOpen, bool TerminatedOutsideHostShutdown); private async Task RunStopScenarioAsync(bool withFix) { var pathA = Path.Combine(Path.GetTempPath(), $"sb140-A-{Guid.NewGuid():N}.db"); var pathB = Path.Combine(Path.GetTempPath(), $"sb140-B-{Guid.NewGuid():N}.db"); await using var dbA = BuildDatabaseProvider(pathA, "node-a"); await using var dbB = BuildDatabaseProvider(pathB, "node-b"); var clock = new Stopwatch(); var probe = new ServerStopProbe(clock); IHost? initiator = null; WebApplication? passive = null; try { passive = BuildStoppingNode(dbB.GetRequiredService(), probe, withFix); await passive.StartAsync(); var akka = passive.Services.GetRequiredService(); var cluster = AkkaCluster.Get(akka.ActorSystem!); await WaitUntilAsync(() => cluster.State.Members.Any(m => m.Status == MemberStatus.Up), "the node to come Up as its own seed"); TimeSpan? memberRemovedAt = null; cluster.RegisterOnMemberRemoved(() => memberRemovedAt ??= clock.Elapsed); var address = passive.Services.GetRequiredService() .Features.Get()!.Addresses.Single(); initiator = await StartInitiatorAsync(dbA.GetRequiredService(), address); // The peer's stream is open and holding a session on the stopping node. var status = passive.Services.GetRequiredService(); await WaitUntilAsync(() => status.Connected, "the peer's sync stream to be open on the stopping node"); var streamWasOpen = status.Connected; clock.Start(); await passive.StopAsync(CancellationToken.None); var elapsed = clock.Elapsed; _output.WriteLine( $"withFix={withFix}: stop {elapsed.TotalMilliseconds:0} ms; member removed at " + $"{memberRemovedAt?.TotalMilliseconds:0} ms; web-server drain began at " + $"{probe.BeganAt?.TotalMilliseconds:0} ms (drain budget {ShutdownTimeout.TotalSeconds:0} s)"); return new StopResult(elapsed, memberRemovedAt, probe.BeganAt, streamWasOpen, akka.TerminatedOutsideHostShutdown); } finally { if (initiator is not null) { try { await initiator.StopAsync(TimeSpan.FromSeconds(5)); } catch { /* teardown */ } initiator.Dispose(); } if (passive is not null) await passive.DisposeAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); foreach (var path in new[] { pathA, pathB }) foreach (var suffix in new[] { "", "-wal", "-shm" }) try { File.Delete(path + suffix); } catch { /* best effort */ } } } private static WebApplication BuildStoppingNode(ILocalDb db, ServerStopProbe probe, bool withFix) { var builder = WebApplication.CreateBuilder(); builder.Environment.ApplicationName = typeof(GracefulStopWithOpenSyncStreamTests).Assembly.GetName().Name!; builder.Logging.ClearProviders(); builder.WebHost.ConfigureKestrel(k => k.Listen(IPAddress.Loopback, 0, o => o.Protocols = HttpProtocols.Http2)); builder.Services.Configure(o => o.ShutdownTimeout = ShutdownTimeout); // The production site interceptor chain: the real auth gate, then (with the fix) the // stream-cancellation interceptor innermost, exactly as Program.cs orders them. builder.Services.AddGrpc(o => { o.Interceptors.Add(); if (withFix) o.Interceptors.Add(); }); builder.Services.AddSingleton(db); builder.Services.AddZbLocalDbReplication(ReplicationConfig(peerAddress: null)); if (!withFix) { // From LocalDb 0.3.8 the library itself ends open sync streams on ApplicationStopping // (scadaproj#57), which would hide the drain in this control. Re-register the service // WITHOUT the host lifetime, i.e. the 0.3.7 behaviour, so the control still reproduces // the bug the host-side fix guards against. builder.Services.Replace(ServiceDescriptor.Singleton(sp => new LocalDbSyncService( sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetRequiredService(), sp.GetService(), sp.GetService(), lifetime: null))); } // The production AkkaHostedService, registered the way Program/SiteServiceRegistration do. // Role "StopProbe" (neither Central nor Site) makes StartAsync register only the dead-letter // monitor; the cluster formation, CoordinatedShutdown and the WhenTerminated watchdog are // the production code paths. var port = FreePort(); builder.Services.AddSingleton(sp => new AkkaHostedService( sp, Options.Create(new NodeOptions { Role = "StopProbe", NodeHostname = "127.0.0.1", RemotingPort = port }), Options.Create(new ClusterOptions { SeedNodes = new List { $"akka.tcp://scadabridge@127.0.0.1:{port}" }, AllowSingleNodeCluster = true, MinNrOfMembers = 1, }), Options.Create(new CommunicationOptions()), sp.GetRequiredService>(), sp.GetRequiredService(), sp.GetRequiredService())); if (withFix) builder.Services.AddHostedService(sp => sp.GetRequiredService()); else builder.Services.AddHostedService(sp => new PreFixHostedService(sp.GetRequiredService())); // Wrap the web server so the start of its drain is timestamped. var serverDescriptor = builder.Services.Last(d => d.ServiceType == typeof(IServer)); builder.Services.Remove(serverDescriptor); builder.Services.AddSingleton(sp => { var inner = serverDescriptor.ImplementationFactory is { } factory ? (IServer)factory(sp) : (IServer)ActivatorUtilities.CreateInstance(sp, serverDescriptor.ImplementationType!); return probe.Wrap(inner); }); var app = builder.Build(); app.MapZbLocalDbSync(); return app; } private static async Task StartInitiatorAsync(ILocalDb db, string peerAddress) { var config = ReplicationConfig(peerAddress); var host = new HostBuilder() .ConfigureServices(services => { services.AddLogging(); services.AddSingleton(db); services.AddZbLocalDbReplication(config); }) .Build(); await host.StartAsync(); return host; } private static IConfiguration ReplicationConfig(string? peerAddress) { var values = new Dictionary { ["LocalDb:Replication:FlushInterval"] = "00:00:00.050", ["LocalDb:Replication:ReconnectBackoffMax"] = "00:00:02", ["LocalDb:Replication:ApiKey"] = ApiKey, }; if (peerAddress is not null) values["LocalDb:Replication:PeerAddress"] = peerAddress; return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); } private static ServiceProvider BuildDatabaseProvider(string path, string nodeName) { string Absent() => Path.Combine(Path.GetTempPath(), $"absent-{Guid.NewGuid():N}.db"); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["LocalDb:Path"] = path, ["ScadaBridge:Node:NodeName"] = nodeName, ["LocalDb:Replication:ApiKey"] = ApiKey, // Legacy migrators pointed at paths that do not exist, so they no-op. ["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={Absent()}", ["ScadaBridge:SiteEventLog:DatabasePath"] = Absent(), ["ScadaBridge:StoreAndForward:SqliteDbPath"] = Absent(), ["ScadaBridge:Database:SiteDbPath"] = Absent(), }) .Build(); return new ServiceCollection() .AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config)) .BuildServiceProvider(); } private static int FreePort() { var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); l.Start(); var p = ((IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return p; } private static async Task WaitUntilAsync(Func condition, string because) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); while (DateTime.UtcNow < deadline) { if (condition()) return; await Task.Delay(50); } Assert.Fail($"Timed out waiting for: {because}"); } /// /// The pre-fix registration shape: the host sees the Akka service only as an /// , so its shutdown runs in StopAsync, after Kestrel's. /// private sealed class PreFixHostedService(AkkaHostedService inner) : IHostedService { public Task StartAsync(CancellationToken cancellationToken) => inner.StartAsync(cancellationToken); public Task StopAsync(CancellationToken cancellationToken) => inner.StopAsync(cancellationToken); } /// Records when the wrapped web server's StopAsync (its graceful drain) begins. private sealed class ServerStopProbe(Stopwatch clock) { public TimeSpan? BeganAt { get; private set; } private void MarkBegan() => BeganAt ??= clock.Elapsed; public IServer Wrap(IServer inner) => new RecordingServer(inner, this); private sealed class RecordingServer(IServer inner, ServerStopProbe probe) : IServer { public IFeatureCollection Features => inner.Features; public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull => inner.StartAsync(application, cancellationToken); public Task StopAsync(CancellationToken cancellationToken) { probe.MarkBegan(); return inner.StopAsync(cancellationToken); } public void Dispose() => inner.Dispose(); } } }