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
@@ -145,17 +145,16 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
/// </summary>
public void Dispose()
{
// One bounded bridge (5 s) for delete + detach, so a dispose never blocks longer than before (review m6).
// DetachAsync swallows its own failures; a failed delete must not skip the detach.
try
{
DisposeBridge.Run(ct => _subscription.DeleteAsync(true, ct));
}
catch
{
}
try
{
DisposeBridge.Run(DetachAsync);
DisposeBridge.Run(async ct =>
{
try { await _subscription.DeleteAsync(true, ct); }
catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
await DetachAsync(ct);
});
}
catch
{
@@ -21,46 +21,76 @@ internal static class HistoryReadPaging
public delegate Task<HistoryReadResult?> PageReader(
byte[]? continuationPoint, bool releaseContinuationPoints, CancellationToken ct);
/// <summary>The bound on a best-effort release that does not ride the caller's (possibly cancelled) token.</summary>
internal static readonly TimeSpan ReleaseTimeout = TimeSpan.FromSeconds(5);
/// <summary>Reads pages until the server finishes or the caller's cap is reached.</summary>
/// <typeparam name="T">The element type extracted from each page.</typeparam>
/// <param name="readPage">Issues one HistoryRead for the single node.</param>
/// <param name="extract">Extracts the page's elements from a result.</param>
/// <param name="maxValues">The caller's cap; <c>&lt;= 0</c> means no cap (follow every continuation point).</param>
/// <param name="maxValues">The caller's cap; must be positive. The result is trimmed to it.</param>
/// <param name="nodeId">The node being read, for refusal messages.</param>
/// <param name="ct">The caller's cancellation token.</param>
/// <returns>Every element read.</returns>
/// <returns>At most <paramref name="maxValues"/> elements.</returns>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="maxValues"/> is not positive: an uncapped client
/// read would materialise a tag's whole window (the client side of review 08 1.1).</exception>
/// <exception cref="ServiceResultException">A page was refused (see <see cref="HistoryReadStatus"/>).</exception>
/// <exception cref="OperationCanceledException"><paramref name="ct"/> was cancelled mid-read; the outstanding
/// cursor is released (best effort) first.</exception>
public static async Task<List<T>> ReadAllAsync<T>(
PageReader readPage, Func<HistoryReadResult, IEnumerable<T>> extract, int maxValues, NodeId nodeId,
CancellationToken ct)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxValues);
var all = new List<T>();
byte[]? continuationPoint = null;
while (true)
{
var result = await readPage(continuationPoint, false, ct).ConfigureAwait(false);
if (result == null) return all;
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
all.AddRange(extract(result));
continuationPoint = result.ContinuationPoint;
if (continuationPoint is not { Length: > 0 }) return all;
if (maxValues > 0 && all.Count >= maxValues) break;
}
// Stopped by the client's own cap: the server still holds a cursor for this session. Release it; failing to
// is not worth failing a read whose values are already in hand.
try
{
await readPage(continuationPoint, true, ct).ConfigureAwait(false);
while (true)
{
var result = await readPage(continuationPoint, false, ct).ConfigureAwait(false);
if (result == null) return Trim(all, maxValues);
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
all.AddRange(extract(result));
continuationPoint = result.ContinuationPoint;
if (continuationPoint is not { Length: > 0 }) return Trim(all, maxValues);
if (all.Count >= maxValues) break;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
catch (OperationCanceledException)
{
// Cancelled between pages (or during page N+1) with a cursor outstanding: release it rather than leave it
// occupying one of the session's continuation-point slots, then surface the cancellation.
if (continuationPoint is { Length: > 0 }) await ReleaseAsync(readPage, continuationPoint, nodeId);
throw;
}
// Stopped by the client's own cap: the server still holds a cursor for this session. Release it — on its own
// bounded token, so a cancellation racing the release cannot lose the values already in hand.
await ReleaseAsync(readPage, continuationPoint, nodeId);
return Trim(all, maxValues);
}
private static List<T> Trim<T>(List<T> all, int maxValues)
{
if (all.Count > maxValues) all.RemoveRange(maxValues, all.Count - maxValues);
return all;
}
private static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId)
{
try
{
using var cts = new CancellationTokenSource(ReleaseTimeout);
await readPage(continuationPoint, true, cts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warning(ex, "Releasing the HistoryRead continuation point for {NodeId} failed", nodeId);
}
return all;
}
}
@@ -35,6 +35,12 @@ public sealed class OpcUaClientService : IOpcUaClientService
// run at a time. 0 = idle, 1 = failover in progress (Interlocked-managed).
private int _failoverInProgress;
// The connection epoch (review I1): ConnectAsync and DisconnectAsync each start a new one (under
// _subscriptionLock). A failover runs under the epoch it started in and abandons itself — disposing any session it
// opened, making no state transition — the moment the epoch moves, so a user Disconnect + Connect during a
// failover is never undone by it. A keep-alive from a previous connection's session cannot start one either.
private long _connectionEpoch;
/// <summary>The most recent keep-alive-triggered failover task (test seam: lets a test await it).</summary>
internal Task? LastFailover { get; private set; }
@@ -109,6 +115,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
throw new InvalidOperationException(
$"Already {_state.ToString().ToLowerInvariant()}; call DisconnectAsync before connecting again.");
long epoch;
lock (_subscriptionLock) epoch = ++_connectionEpoch;
_settings = settings;
_allEndpointUrls = FailoverUrlParser.Parse(settings.EndpointUrl, settings.FailoverUrls);
_currentEndpointIndex = 0;
@@ -118,11 +127,11 @@ public sealed class OpcUaClientService : IOpcUaClientService
try
{
var session = await ConnectToEndpointAsync(settings, _allEndpointUrls[0], ct);
_session = session;
lock (_subscriptionLock) _session = session;
session.RegisterKeepAliveHandler(isGood =>
{
if (!isGood) LastFailover = HandleKeepAliveFailureAsync();
if (!isGood) OnKeepAliveFailed(epoch);
});
CurrentConnectionInfo = BuildConnectionInfo(session);
@@ -143,6 +152,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
if (_state == ConnectionState.Disconnected)
return;
// A new epoch first: a failover still connecting abandons itself instead of resurrecting the connection.
lock (_subscriptionLock) _connectionEpoch++;
var endpointUrl = _session?.EndpointUrl ?? _settings?.EndpointUrl ?? string.Empty;
// Snapshot the subscription adapter references under the lock so a concurrent
@@ -314,11 +326,18 @@ public sealed class OpcUaClientService : IOpcUaClientService
subscription = _dataSubscription;
}
if (subscription != null) await subscription.RemoveMonitoredItemAsync(sub.Handle, ct);
lock (_subscriptionLock)
try
{
_activeDataSubscriptions.Remove(nodeIdStr);
if (subscription != null) await subscription.RemoveMonitoredItemAsync(sub.Handle, ct);
}
finally
{
// The caller asked to stop: drop the bookkeeping even when the server-side remove failed (a session
// that died under us), or a later failover would replay the item onto the new session (review m5).
lock (_subscriptionLock)
{
_activeDataSubscriptions.Remove(nodeIdStr);
}
}
}
finally
@@ -698,8 +717,12 @@ public sealed class OpcUaClientService : IOpcUaClientService
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
lock (_subscriptionLock)
{
if (_disposed) return;
_disposed = true;
_connectionEpoch++; // a failover still running abandons itself
}
_dataSubscription?.Dispose();
_alarmSubscription?.Dispose();
@@ -745,19 +768,27 @@ public sealed class OpcUaClientService : IOpcUaClientService
ct);
}
private async Task HandleKeepAliveFailureAsync()
/// <summary>A bad keep-alive from the session of connection <paramref name="epoch"/>.</summary>
private void OnKeepAliveFailed(long epoch)
{
// Serialize failover: the OPC UA stack raises KeepAlive repeatedly
// while a session is down, so multiple bad keep-alives can fire before
// the first failover loop finishes. CompareExchange atomically claims
// the failover slot; a re-entrant call sees 1 and returns immediately,
// guaranteeing exactly one failover loop runs at a time.
// A late keep-alive from a previous connection's session is not about the current one.
if (!IsCurrentEpoch(epoch)) return;
// Serialize failover: the OPC UA stack raises KeepAlive repeatedly while a session is down, so several bad
// keep-alives can fire before the first failover loop finishes. CompareExchange claims the failover slot; a
// re-entrant call sees 1 and returns, so exactly one loop runs — and only the winner sets the test seam
// (review m8: a re-entrant call used to overwrite it with an already-completed no-op).
if (Interlocked.CompareExchange(ref _failoverInProgress, 1, 0) != 0)
return;
LastFailover = HandleKeepAliveFailureAsync(epoch);
}
private async Task HandleKeepAliveFailureAsync(long epoch)
{
try
{
await RunFailoverAsync();
await RunFailoverAsync(epoch);
}
finally
{
@@ -765,14 +796,39 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
}
private async Task RunFailoverAsync()
private bool IsCurrentEpoch(long epoch)
{
lock (_subscriptionLock) return _connectionEpoch == epoch && !_disposed;
}
/// <summary>
/// A state transition made only while <paramref name="epoch"/> is still current (checked and applied under
/// <see cref="_subscriptionLock"/>, the event raised outside it).
/// </summary>
/// <returns><see langword="false"/> when the epoch has moved on (the caller must abandon).</returns>
private bool TransitionStateIfCurrent(long epoch, ConnectionState newState, string endpointUrl)
{
ConnectionState oldState;
lock (_subscriptionLock)
{
if (_connectionEpoch != epoch || _disposed) return false;
oldState = _state;
if (oldState == newState) return true;
_state = newState;
}
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
return true;
}
private async Task RunFailoverAsync(long epoch)
{
if (_state == ConnectionState.Reconnecting || _state == ConnectionState.Disconnected)
return;
var oldSession = _session;
var oldEndpoint = oldSession?.EndpointUrl ?? string.Empty;
TransitionState(ConnectionState.Reconnecting, oldEndpoint);
if (!TransitionStateIfCurrent(epoch, ConnectionState.Reconnecting, oldEndpoint)) return;
Logger.Warning("Session lost on {EndpointUrl}. Attempting failover...", oldEndpoint);
// Dispose the dead session BEFORE taking the gate: an in-flight subscribe holding the gate is blocked on
@@ -793,47 +849,60 @@ public sealed class OpcUaClientService : IOpcUaClientService
await _subscriptionGate.WaitAsync();
try
{
// The user's settings / endpoint list as of THIS epoch: a Connect during the failover replaces the fields.
ConnectionSettings? settings;
string[]? endpoints;
lock (_subscriptionLock)
{
if (_connectionEpoch != epoch || _disposed) return; // the user disconnected / reconnected meanwhile
if (ReferenceEquals(_session, oldSession)) _session = null;
_dataSubscription = null;
_alarmSubscription = null;
settings = _settings;
endpoints = _allEndpointUrls;
}
if (_settings == null || _allEndpointUrls == null)
if (settings == null || endpoints == null)
{
TransitionState(ConnectionState.Disconnected, oldEndpoint);
TransitionStateIfCurrent(epoch, ConnectionState.Disconnected, oldEndpoint);
return;
}
// Try each endpoint
for (var attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
for (var attempt = 0; attempt < endpoints.Length; attempt++)
{
_currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length;
var url = _allEndpointUrls[_currentEndpointIndex];
if (!IsCurrentEpoch(epoch)) return; // stop trying once the user has moved on (m5)
_currentEndpointIndex = (_currentEndpointIndex + 1) % endpoints.Length;
var url = endpoints[_currentEndpointIndex];
try
{
Logger.Information("Failover attempt to {EndpointUrl}", url);
var session = await ConnectToEndpointAsync(_settings, url, CancellationToken.None);
var session = await ConnectToEndpointAsync(settings, url, CancellationToken.None);
// A DisconnectAsync / Dispose that ran while this attempt was connecting wins: do not resurrect
// the connection behind the caller's back.
if (_disposed || _state == ConnectionState.Disconnected)
// A Disconnect / Connect / Dispose that ran while this attempt was connecting wins: never
// resurrect the old connection or overwrite the user's new session. Checked and installed
// atomically against the epoch.
bool installed;
lock (_subscriptionLock)
{
installed = _connectionEpoch == epoch && !_disposed;
if (installed) _session = session;
}
if (!installed)
{
session.Dispose();
return;
}
_session = session;
session.RegisterKeepAliveHandler(isGood =>
{
if (!isGood) LastFailover = HandleKeepAliveFailureAsync();
if (!isGood) OnKeepAliveFailed(epoch);
});
CurrentConnectionInfo = BuildConnectionInfo(session);
TransitionState(ConnectionState.Connected, url);
if (!TransitionStateIfCurrent(epoch, ConnectionState.Connected, url)) return;
Logger.Information("Failover succeeded to {EndpointUrl}", url);
// Replay subscriptions
@@ -846,8 +915,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
}
Logger.Error("All failover endpoints unreachable");
TransitionState(ConnectionState.Disconnected, oldEndpoint);
if (TransitionStateIfCurrent(epoch, ConnectionState.Disconnected, oldEndpoint))
Logger.Error("All failover endpoints unreachable");
}
finally
{
@@ -1023,9 +1092,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
private void TransitionState(ConnectionState newState, string endpointUrl)
{
var oldState = _state;
if (oldState == newState) return;
_state = newState;
ConnectionState oldState;
lock (_subscriptionLock)
{
oldState = _state;
if (oldState == newState) return;
_state = newState;
}
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
}
@@ -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>