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
This commit is contained in:
Joseph Doherty
2026-10-22 17:18:26 -04:00
parent 31605e547d
commit 0c9e4f4e3d
7 changed files with 442 additions and 74 deletions
@@ -0,0 +1,177 @@
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);
}
}
@@ -21,6 +21,9 @@ internal sealed class FakeSessionFactory : ISessionFactory
/// </summary>
public TaskCompletionSource? CreateGate { get; set; }
/// <summary>Completed when a call observes <see cref="CreateGate" />.</summary>
public TaskCompletionSource CreateEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the list of sessions created via CreateSessionAsync.</summary>
public IReadOnlyList<FakeSessionAdapter> CreatedSessions => _createdSessions;
@@ -39,8 +42,14 @@ internal sealed class FakeSessionFactory : ISessionFactory
CreateCallCount++;
LastEndpointUrl = endpoint.EndpointUrl;
if (CreateGate != null)
await CreateGate.Task;
// One-shot: the gate holds only the call that observed it, so a later call (e.g. a user's Connect while a
// failover is held) runs freely.
if (CreateGate is { } gate)
{
CreateGate = null;
CreateEntered.TrySetResult();
await gate.Task;
}
if (ThrowOnCreate)
throw new InvalidOperationException("FakeSessionFactory configured to fail.");
@@ -37,6 +37,9 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
/// <summary>Gets the number of times AddEventMonitoredItemAsync was called.</summary>
public int AddEventCount { get; private set; }
/// <summary>Gets or sets a value indicating whether RemoveMonitoredItemAsync throws (a session that died).</summary>
public bool ThrowOnRemove { get; set; }
/// <summary>Gets the number of times RemoveMonitoredItemAsync was called.</summary>
public int RemoveCount { get; private set; }
@@ -70,6 +73,8 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
/// <inheritdoc />
public Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct)
{
if (ThrowOnRemove)
throw new ServiceResultException(StatusCodes.BadSessionClosed, "Session closed");
lock (_itemsLock)
{
RemoveCount++;
@@ -92,17 +92,92 @@ public class HistoryReadPagingTests
server.Calls.ShouldHaveSingleItem().Release.ShouldBeFalse();
}
/// <summary>An uncapped read (<c>maxValues &lt;= 0</c>) follows every continuation point to the end.</summary>
[Fact]
public async Task An_uncapped_read_follows_every_page()
/// <summary>
/// Review m3: a non-positive cap is refused. An uncapped client read would materialise a tag's whole window
/// (the client side of R08 1.1), and <c>(uint)-1</c> on the wire asked the server for everything.
/// </summary>
/// <param name="max">The cap.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
public async Task A_non_positive_cap_is_refused_before_any_read(int max)
{
var server = new ScriptedServer((2, [1]), (2, [2]), (1, null));
var server = new ScriptedServer();
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 0, Node,
await Should.ThrowAsync<ArgumentOutOfRangeException>(() =>
HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, max, Node, CancellationToken.None));
server.Calls.ShouldBeEmpty();
}
/// <summary>Review m2: server pages that overshoot the cap are trimmed to it (60 + 60 with a cap of 100 is 100).</summary>
[Fact]
public async Task The_result_is_trimmed_to_the_cap()
{
var server = new ScriptedServer((60, [1]), (60, [2]));
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 100, Node,
CancellationToken.None);
values.Count.ShouldBe(5);
server.Calls.ShouldAllBe(c => !c.Release);
values.Count.ShouldBe(100);
server.Calls.Last().Release.ShouldBeTrue(); // the outstanding cursor is still released
}
/// <summary>
/// Review m1: a read cancelled between pages releases the outstanding cursor (best effort, not on the caller's
/// cancelled token) and still surfaces the cancellation. Positive control: the release IS recorded.
/// </summary>
[Fact]
public async Task A_read_cancelled_mid_paging_releases_the_cursor_and_rethrows()
{
using var cts = new CancellationTokenSource();
var calls = new List<(byte[]? Cp, bool Release, bool TokenCancelled)>();
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct)
{
calls.Add((cp, release, ct.IsCancellationRequested));
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
if (cp != null) { cts.Cancel(); ct.ThrowIfCancellationRequested(); }
var data = new HistoryData();
data.DataValues.Add(new DataValue(new Variant(1)));
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
{
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
});
}
await Should.ThrowAsync<OperationCanceledException>(() =>
HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 10, Node, cts.Token));
var release = calls.Where(c => c.Release).ShouldHaveSingleItem();
release.Cp.ShouldBe(new byte[] { 5 });
release.TokenCancelled.ShouldBeFalse();
}
/// <summary>A cancellation that lands while the cap release is in flight does not lose the values read.</summary>
[Fact]
public async Task The_cap_release_does_not_ride_the_callers_token()
{
using var cts = new CancellationTokenSource();
var releaseSawCancelled = (bool?)null;
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct)
{
if (release)
{
releaseSawCancelled = ct.IsCancellationRequested;
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
}
var data = new HistoryData();
data.DataValues.Add(new DataValue(new Variant(1)));
cts.Cancel(); // the caller gives up right as the capped page arrives
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
{
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
});
}
var values = await HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 1, Node, cts.Token);
values.Count.ShouldBe(1);
releaseSawCancelled.ShouldBe(false);
}
/// <summary>A failed release is best effort: the values already read are still returned.</summary>