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 →
/// SecretReplicationActorProvider → ISecretCacheInvalidator →
/// DefaultSecretResolver → ISecretStore. 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);
}
}
}
}