Files
lmxopcua/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/FailoverEpochTests.cs
T
Joseph Doherty 0c9e4f4e3d fix(client): a failover abandons itself once the user disconnects or reconnects; paging and teardown minors
Review I1: ConnectAsync, DisconnectAsync and Dispose each start a new connection
epoch under _subscriptionLock. A keep-alive failover runs under the epoch it
started in and abandons itself the moment it moves: it installs its session only
if the epoch still matches (checked and assigned under the lock, otherwise the
session is disposed), its state transitions are epoch-guarded, it stops trying
endpoints, and it never flips a user's new connection to Disconnected. A late
keep-alive from a previous connection's session starts no failover. Gate-driven
tests cover the success and all-fail arms, the stale keep-alive, with a genuine
failover as the positive control (4 of them red before).

Minors: m8 only the CompareExchange winner sets LastFailover; m5 the endpoint
loop stops early, and a failed Unsubscribe still drops its bookkeeping; m1 a read
cancelled mid-paging releases its cursor (bounded token) and rethrows, and the cap
release no longer rides the caller's token; m2 results are trimmed to the cap;
m3 a non-positive cap is refused (CLI and UI already bound it to >= 1); m6 the
subscription adapter's Dispose uses one bounded bridge for delete + detach.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:18:26 -04:00

178 lines
7.4 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests;
/// <summary>
/// Lane K review I1: a keep-alive failover belongs to the connection it started under. A user Disconnect + Connect
/// while it is still connecting starts a new connection epoch; the stale failover must abandon itself — never
/// overwrite the user's new session, never flip its state. Every step is gate-driven (no sleeps).
/// </summary>
public sealed class FailoverEpochTests : IDisposable
{
private const string Primary = "opc.tcp://primary:4840";
private const string Backup = "opc.tcp://backup:4840";
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(10);
private readonly FakeSessionFactory _factory = new();
private readonly OpcUaClientService _service;
private readonly List<ConnectionState> _states = [];
public FailoverEpochTests()
{
_service = new OpcUaClientService(new FakeApplicationConfigurationFactory(), new FakeEndpointDiscovery(), _factory);
_service.ConnectionStateChanged += (_, e) => { lock (_states) _states.Add(e.NewState); };
}
public void Dispose() => _service.Dispose();
private static ConnectionSettings Settings() => new()
{
EndpointUrl = Primary, FailoverUrls = [Backup], SessionTimeoutSeconds = 60
};
/// <summary>Connects S1, then holds a keep-alive failover inside its connect attempt.</summary>
private async Task<(FakeSessionAdapter S1, Task Failover, TaskCompletionSource Gate)> StartHeldFailoverAsync()
{
var s1 = new FakeSessionAdapter();
_factory.EnqueueSession(s1);
await _service.ConnectAsync(Settings());
var gate = new TaskCompletionSource();
_factory.CreateGate = gate;
s1.SimulateKeepAlive(false);
var failover = _service.LastFailover.ShouldNotBeNull();
await _factory.CreateEntered.Task.WaitAsync(Wait);
return (s1, failover, gate);
}
/// <summary>The stale failover's attempt SUCCEEDS after the user reconnected: its session is disposed, the user's stays.</summary>
[Fact]
public async Task A_stale_failover_that_connects_does_not_replace_the_users_new_session()
{
var (_, failover, gate) = await StartHeldFailoverAsync();
var userSession = new FakeSessionAdapter();
var failoverSession = new FakeSessionAdapter();
_factory.EnqueueSession(userSession); // dequeued by the user's Connect (the failover is still held)
_factory.EnqueueSession(failoverSession); // dequeued by the failover once released
await _service.DisconnectAsync();
await _service.ConnectAsync(Settings());
lock (_states) _states.Clear();
gate.SetResult();
await failover.WaitAsync(Wait);
failoverSession.Disposed.ShouldBeTrue();
userSession.Disposed.ShouldBeFalse();
_service.IsConnected.ShouldBeTrue();
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe(Primary);
lock (_states) _states.ShouldBeEmpty(); // the stale failover made no state transition at all
}
/// <summary>The stale failover's attempts all FAIL after the user reconnected: the user's connection stays Connected.</summary>
[Fact]
public async Task A_stale_failover_that_fails_does_not_flip_the_users_connection_to_Disconnected()
{
var (_, failover, gate) = await StartHeldFailoverAsync();
_factory.EnqueueSession(new FakeSessionAdapter());
await _service.DisconnectAsync();
await _service.ConnectAsync(Settings());
lock (_states) _states.Clear();
var createsBefore = _factory.CreateCallCount;
_factory.ThrowOnCreate = true;
gate.SetResult();
await failover.WaitAsync(Wait);
_service.IsConnected.ShouldBeTrue();
lock (_states) _states.ShouldBeEmpty();
// It stopped at the abandoned attempt instead of trying every remaining endpoint (review m5).
_factory.CreateCallCount.ShouldBe(createsBefore);
// And the user can still disconnect / reconnect normally afterwards.
_factory.ThrowOnCreate = false;
await _service.DisconnectAsync();
await _service.ConnectAsync(Settings());
_service.IsConnected.ShouldBeTrue();
}
/// <summary>Positive control: an undisturbed failover still reconnects to the backup.</summary>
[Fact]
public async Task A_genuine_failover_still_reconnects()
{
var (_, failover, gate) = await StartHeldFailoverAsync();
_factory.EnqueueSession(new FakeSessionAdapter());
gate.SetResult();
await failover.WaitAsync(Wait);
_service.IsConnected.ShouldBeTrue();
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe(Backup);
lock (_states) _states.ShouldContain(ConnectionState.Reconnecting);
}
/// <summary>
/// A late bad keep-alive from the PREVIOUS connection's session must not start a failover against the new one.
/// Positive control: the same signal from the current session does (the genuine-failover test above).
/// </summary>
[Fact]
public async Task A_keep_alive_from_a_previous_connection_does_not_fail_over_the_current_one()
{
var s1 = new FakeSessionAdapter();
_factory.EnqueueSession(s1);
await _service.ConnectAsync(Settings());
await _service.DisconnectAsync();
_factory.EnqueueSession(new FakeSessionAdapter());
await _service.ConnectAsync(Settings());
var creates = _factory.CreateCallCount;
lock (_states) _states.Clear();
s1.SimulateKeepAlive(false);
if (_service.LastFailover is { } t) await t.WaitAsync(Wait);
_factory.CreateCallCount.ShouldBe(creates);
lock (_states) _states.ShouldBeEmpty();
_service.IsConnected.ShouldBeTrue();
}
/// <summary>Review m8: re-entrant bad keep-alives do not overwrite the seam with an already-completed no-op.</summary>
[Fact]
public async Task Reentrant_keep_alives_leave_the_seam_on_the_running_failover()
{
var (s1, failover, gate) = await StartHeldFailoverAsync();
s1.SimulateKeepAlive(false);
s1.SimulateKeepAlive(false);
_service.LastFailover.ShouldBeSameAs(failover);
failover.IsCompleted.ShouldBeFalse();
_factory.EnqueueSession(new FakeSessionAdapter());
gate.SetResult();
await failover.WaitAsync(Wait);
}
/// <summary>
/// Review m5: an Unsubscribe whose server-side remove fails (the session died under it) still drops the
/// bookkeeping, so a failover does not replay an item the caller asked to stop. Proven by a re-subscribe of the
/// same node creating a fresh monitored item instead of returning "already subscribed".
/// </summary>
[Fact]
public async Task A_failed_unsubscribe_still_forgets_the_item()
{
var sub = new FakeSubscriptionAdapter();
_factory.EnqueueSession(new FakeSessionAdapter { NextSubscription = sub });
await _service.ConnectAsync(Settings());
var node = new Opc.Ua.NodeId("ns=2;s=X");
await _service.SubscribeAsync(node);
sub.ThrowOnRemove = true;
await Should.ThrowAsync<Opc.Ua.ServiceResultException>(() => _service.UnsubscribeAsync(node));
sub.ThrowOnRemove = false;
await _service.SubscribeAsync(node);
sub.AddDataChangeCount.ShouldBe(2);
}
}