using Akka.Actor; using Akka.Cluster; using Akka.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Replication; using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection; namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests.DependencyInjection; /// /// Builds the replication graph the way a real application host does — container-registered /// , hosted services started, services resolved from the built provider — /// and asserts resolution actually completes. /// /// /// /// This is the regression test for scadaproj#1. The 0.2.x graph had a circular singleton /// dependency reachable only through the container: ISecretStore (the /// ReplicatingSecretStore decorator) → ISecretReplicator → /// SecretReplicationActorProviderISecretCacheInvalidator → /// DefaultSecretResolverISecretStore. Every edge is an opaque factory lambda, so /// the container cannot detect the cycle statically; at runtime the resolution recurses until /// MS.DI's StackGuard moves it onto a fresh thread-pool thread, which then blocks forever on /// a singleton call-site lock the first thread still holds. The symptom was a silent, permanent /// hang — not a stack overflow, not an exception — which is why every direct-construction test /// passed while any hosted process froze at startup. /// /// /// The suite's other cluster tests construct the actor with system.ActorOf directly and can /// never see this defect. Nothing here may resolve the affected services before the assertions do. /// /// public sealed class HostedProcessResolutionTests : IAsyncLifetime { // Generous next to the milliseconds a healthy resolve takes, tiny next to CI noise: on // regression the resolve NEVER completes, so the only cost of margin is this test's runtime. private static readonly TimeSpan ResolveBudget = TimeSpan.FromSeconds(20); private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"zb-hosted-di-{Guid.NewGuid():N}.db"); private ActorSystem _system = null!; private ServiceProvider _provider = null!; public async Task InitializeAsync() { // A single self-joined cluster node: `actor.provider = cluster` is what the replication // actor's DistributedPubSub mediator needs, and joining makes the rig an honest miniature // of an app node rather than a special case. _system = ActorSystem.Create("zb-secrets-cluster", ConfigurationFactory.ParseString(""" akka { loglevel = WARNING actor.provider = cluster remote.dot-netty.tcp { hostname = "127.0.0.1" public-hostname = "127.0.0.1" port = 0 } } """).WithFallback(AkkaSecretsReplication.SerializationConfig)); Cluster cluster = Cluster.Get(_system); cluster.Join(cluster.SelfAddress); DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20); while (cluster.State.Members.All(m => m.Status != MemberStatus.Up)) { if (DateTime.UtcNow > deadline) { throw new TimeoutException("Single-node cluster never reached Up."); } await Task.Delay(100); } IConfigurationRoot config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = _dbPath, ["Secrets:RunMigrationsOnStartup"] = "true", ["Secrets:Replication:AnnounceInterval"] = "00:00:30", }) .Build(); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(_system); services.AddZbSecretsAkkaReplication(config, "Secrets", "Secrets:Replication"); _provider = services.BuildServiceProvider(); // Started exactly as a host would, BEFORE anything resolves the replicated store — the // order OtOpcUa's startup follows, and the order under which the deadlock was reproduced. foreach (IHostedService hosted in _provider.GetServices()) { await hosted.StartAsync(CancellationToken.None); } } [Fact] public async Task Resolving_the_replicated_graph_through_the_container_completes() { // One test covers all three entry points IN this order: the store is the edge OtOpcUa's // startup hook actually resolves, the replicator is the narrowest reproduction of the // hang, and the resolver closes the loop from the invalidator's side. Split into separate // facts, each would rebuild the rig only to hit the same three singletons. ISecretStore store = await ResolveWithinBudgetAsync( sp => sp.GetRequiredService(), nameof(ISecretStore)); ISecretReplicator replicator = await ResolveWithinBudgetAsync( sp => sp.GetRequiredService(), nameof(ISecretReplicator)); ISecretResolver resolver = await ResolveWithinBudgetAsync( sp => sp.GetRequiredService(), nameof(ISecretResolver)); Assert.IsType(store); Assert.IsType(replicator); Assert.NotNull(resolver); } private async Task ResolveWithinBudgetAsync( Func resolve, string label) { // The failure mode under test is an infinite wait inside the container, so the resolve // runs on the pool with a watchdog. On regression the worker thread is unrecoverable — // acceptable in a test process that is about to exit, and vastly better than hanging CI. Task resolution = Task.Run(() => resolve(_provider)); Task first = await Task.WhenAny(resolution, Task.Delay(ResolveBudget)); Assert.True( first == resolution, $"Resolving {label} did not complete within {ResolveBudget.TotalSeconds:0}s — " + "the DI singleton cycle deadlock (scadaproj#1) has regressed."); return await resolution; } public async Task DisposeAsync() { await _provider.DisposeAsync(); await _system.Terminate(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); foreach (string path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" }) { if (File.Exists(path)) { File.Delete(path); } } } }