using System.Net;
using System.Net.Sockets;
using Akka.Actor;
using Akka.Cluster;
using Akka.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
///
/// Guards the self-first seed-ordering invariant (decision 2026-07-22): every node that is a seed
/// of its own mesh lists ITSELF as SeedNodes[0] and its partner second.
///
///
///
/// Why the ordering is the mechanism. Akka runs a different bootstrap process
/// depending on whether seed-nodes[0] is this node's own address. When it is,
/// FirstSeedNodeProcess runs: it InitJoins the other seeds and self-joins once
/// seed-node-timeout (5 s) passes with nobody answering. When it is not,
/// JoinSeedNodeProcess runs, and that process can never form a new cluster — it
/// retries InitJoin forever. That is why "both peers are listed in SeedNodes" never
/// meant "either can cold-start alone", and why a node listed second stayed out of the
/// cluster indefinitely while its peer was down.
///
///
/// Why not the self-form watchdog this replaces. ClusterBootstrapFallback
/// waited Cluster:SelfFormAfter for membership and then called
/// Cluster.Join(SelfAddress). A timer outside Akka's join handshake cannot
/// distinguish "no seed answered" from "a seed answered and the join is in flight", and
/// Join(SelfAddress) is not ignored mid-handshake — it wins. The live gate on
/// the docker-dev rig (2026-07-22) caught exactly that: a node bounced by a manual failover
/// restarted, got InitJoinAck from its live peer, but no Welcome inside the window
/// because the peer's ring still held its previous incarnation
/// (Exiting → Down → Removed); the watchdog fired and formed a SECOND
/// cluster. A TCP reachability guard patched that one shape; the race stayed.
/// is that scenario,
/// and self-first ordering has no such race because the decision is made inside the
/// handshake by Akka itself.
///
///
/// Why these start real hosts through the production bootstrap. Same reason as
/// : the question is what a running node does.
/// Every node here is built with
/// — the shipped entry
/// point — so these run at the production failure-detection envelope (heartbeat 2 s,
/// acceptable pause 10 s, auto-down 15 s, down-removal-margin 15 s) from
/// Resources/akka.conf rather than at timings invented by the test.
///
///
public sealed class SelfFirstSeedBootstrapTests
{
private static int FreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
///
/// Starts a node the way every shipped node config is written: its own address first, the
/// partner second.
///
/// Actor-system name; also the system part of every seed address.
/// This node's remoting port.
/// The partner's remoting port (may have nothing listening on it).
///
/// reproduces the pre-2026-07-22 ordering (partner first), which is what
/// makes a falsifiability
/// control rather than a restatement of the fix.
///
private static async Task StartNodeAsync(
string systemName,
int selfPort,
int peerPort,
bool selfFirst = true)
{
var self = $"akka.tcp://{systemName}@127.0.0.1:{selfPort}";
var peer = $"akka.tcp://{systemName}@127.0.0.1:{peerPort}";
var options = new AkkaClusterOptions
{
SystemName = systemName,
Hostname = "127.0.0.1",
PublicHostname = "127.0.0.1",
Port = selfPort,
Roles = new[] { "admin", "driver" },
SeedNodes = selfFirst ? new[] { self, peer } : new[] { peer, self },
};
var builder = Host.CreateDefaultBuilder();
builder.ConfigureServices(services =>
{
services.AddSingleton>(Options.Create(options));
services.AddAkka(systemName, (ab, sp) =>
{
ab.WithOtOpcUaClusterBootstrap(sp);
// Crash simulation, test-only: with this off, ActorSystem.Terminate() skips
// CoordinatedShutdown and so gossips no Leave — a process death, not a graceful
// drain. Without it "restart the standby" would degenerate into the easy path where
// the peer had already removed the old member before the new one dialled in.
// Prepend is Akka.Hosting's highest-precedence merge mode (see BuildDowningHocon).
ab.AddHocon(
"akka.coordinated-shutdown.run-by-actor-system-terminate = off",
HoconAddMode.Prepend);
});
});
var host = builder.Build();
await host.StartAsync();
return host;
}
/// Polls until the node sees Up members, or the deadline passes.
private static async Task WaitForUpMembersAsync(IHost host, int expected, TimeSpan timeout)
{
var cluster = Akka.Cluster.Cluster.Get(host.Services.GetRequiredService());
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expected)
{
return true;
}
await Task.Delay(200);
}
return cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expected;
}
private static async Task StopAsync(IHost host)
{
try
{
await host.StopAsync();
}
catch (Exception)
{
// Teardown only. A host whose ActorSystem was terminated by a crash-simulation cannot
// run CoordinatedShutdown again; that must not fail the test that already asserted.
}
host.Dispose();
}
///
/// The unattended cold-start-alone guarantee: a node whose partner is dead forms the cluster by
/// itself, through Akka's own FirstSeedNodeProcess — no watchdog, no timer, no operator.
///
[Fact]
public async Task Lone_cold_start_forms_a_cluster_when_the_peer_is_dead()
{
var selfPort = FreePort();
var deadPeerPort = FreePort(); // nothing is listening there
var host = await StartNodeAsync("otopcua-selffirst-1", selfPort, deadPeerPort);
try
{
// seed-node-timeout is Akka's 5 s default; 30 s is slack for a loaded CI box.
(await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(30)))
.ShouldBeTrue("a self-first seed must form a cluster alone when no peer answers InitJoin");
}
finally
{
await StopAsync(host);
}
}
///
/// FALSIFIABILITY CONTROL for the test above. With the OLD ordering — partner first — the
/// identical scenario never comes Up, because JoinSeedNodeProcess has no self-join path.
/// If this test ever starts passing quickly, the seed ordering has stopped being the thing doing
/// the work and the suite above has gone vacuous.
///
///
/// This is also the test that fails while the retired ClusterBootstrapFallback watchdog
/// is still armed: that timer would bring this node Up at SelfFormAfter (10 s) despite the
/// ordering, which is precisely the "something other than Akka's handshake is deciding" that the
/// retirement removes.
///
[Fact]
public async Task Peer_first_ordering_is_the_outage_gap_and_never_forms()
{
var selfPort = FreePort();
var deadPeerPort = FreePort();
var host = await StartNodeAsync("otopcua-selffirst-2", selfPort, deadPeerPort, selfFirst: false);
try
{
// 3x the 5 s seed-node-timeout, and longer than the retired watchdog's 10 s window.
(await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(15)))
.ShouldBeFalse("a node listed behind its peer cannot form a cluster — it InitJoin-loops forever");
var cluster = Akka.Cluster.Cluster.Get(host.Services.GetRequiredService());
cluster.State.Members.ShouldBeEmpty();
// Positive control: the node was formable all along; only the ordering blocked it.
cluster.Join(cluster.SelfAddress);
(await WaitForUpMembersAsync(host, 1, TimeSpan.FromSeconds(30)))
.ShouldBeTrue("positive control — an explicit self-join must bring this node Up");
}
finally
{
await StopAsync(host);
}
}
///
/// The case that killed the self-form watchdog (live gate, docker-dev, 2026-07-22): a routine
/// standby restart while the peer is alive must REJOIN, never island. Under self-first ordering
/// the restarted node's FirstSeedNodeProcess only self-joins when no seed answers, so the
/// live peer's InitJoinAck keeps it on the join path however long the peer takes to
/// retire its previous incarnation.
///
[Fact]
public async Task Restarting_node_rejoins_its_live_peer_instead_of_islanding()
{
const string systemName = "otopcua-selffirst-3";
var portA = FreePort();
var portB = FreePort();
var nodeA = await StartNodeAsync(systemName, portA, portB);
IHost? nodeB = null;
IHost? nodeB2 = null;
try
{
(await WaitForUpMembersAsync(nodeA, 1, TimeSpan.FromSeconds(30))).ShouldBeTrue();
nodeB = await StartNodeAsync(systemName, portB, portA);
(await WaitForUpMembersAsync(nodeA, 2, TimeSpan.FromSeconds(60)))
.ShouldBeTrue("the pair must converge before the restart is meaningful");
// Hard-crash B and immediately restart it at the SAME address, as a service restart or
// container recreate does. No Leave gossip: A still holds B's previous incarnation.
await nodeB.Services.GetRequiredService().Terminate()
.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
nodeB2 = await StartNodeAsync(systemName, portB, portA);
// ONE cluster of two, never two clusters of one. The budget covers auto-down (15 s) plus
// down-removal-margin (15 s) before the new incarnation is admitted.
(await WaitForUpMembersAsync(nodeB2, 2, TimeSpan.FromSeconds(120)))
.ShouldBeTrue("the restarted node must rejoin its live peer, not form a second cluster");
(await WaitForUpMembersAsync(nodeA, 2, TimeSpan.FromSeconds(120)))
.ShouldBeTrue("the surviving node must see the restarted peer as a member of ITS cluster");
}
finally
{
if (nodeB2 is not null) await StopAsync(nodeB2);
if (nodeB is not null) await StopAsync(nodeB);
await StopAsync(nodeA);
}
}
///
/// The obvious objection to self-first on BOTH nodes: does a simultaneous cold start produce two
/// clusters? While the two are mutually reachable it does not — each runs
/// FirstSeedNodeProcess, and the InitJoin handshake resolves which one forms before
/// either self-join deadline expires. (A genuine boot-time PARTITION would still split, the same
/// dual-active class the auto-down strategy already accepts, with the same recovery.)
///
[Fact]
public async Task Both_nodes_cold_starting_together_converge_on_one_cluster()
{
const string systemName = "otopcua-selffirst-4";
var portA = FreePort();
var portB = FreePort();
var nodeA = await StartNodeAsync(systemName, portA, portB);
var nodeB = await StartNodeAsync(systemName, portB, portA);
try
{
(await WaitForUpMembersAsync(nodeA, 2, TimeSpan.FromSeconds(60)))
.ShouldBeTrue("both nodes cold-starting self-first must converge, not split");
(await WaitForUpMembersAsync(nodeB, 2, TimeSpan.FromSeconds(60)))
.ShouldBeTrue("both nodes cold-starting self-first must converge, not split");
}
finally
{
await StopAsync(nodeB);
await StopAsync(nodeA);
}
}
}