0c9e4f4e3d
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
178 lines
7.4 KiB
C#
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);
|
|
}
|
|
}
|