/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
// CA2007: tests run without a SynchronizationContext; ConfigureAwait(false)
// adds noise without a behavioural benefit. Disabled file-level for the suite.
#pragma warning disable CA2007
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Moq;
using NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.Client.Tests.ManagedSession
{
///
/// Unit tests for state
/// transitions, reconnect policy integration, and lifecycle.
///
[TestFixture]
public sealed class ConnectionStateMachineTests
{
private ILogger m_logger;
[OneTimeSetUp]
public void OneTimeSetUp()
{
m_logger = NUnitTelemetryContext.Create()
.CreateLogger("ConnectionStateMachine");
}
private ConnectionStateMachine CreateMachine(
IReconnectPolicy? policy = null)
{
return new ConnectionStateMachine(
policy ??
new ReconnectPolicy
{
JitterFactor = 0.0,
Strategy = BackoffStrategy.Constant,
InitialDelay = TimeSpan.FromMilliseconds(10)
},
m_logger);
}
///
/// Helper that waits for the state machine to reach a
/// specific state, with a timeout. Also handles the case
/// where the target state was already reached and passed.
///
private static async Task WaitForStateAsync(
ConnectionStateMachine sm,
ConnectionState target,
int timeoutMs = 5000)
{
var tcs = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
void Handler(object? sender, ConnectionStateChangedEventArgs e)
{
if (e.NewState == target)
{
tcs.TrySetResult(true);
}
}
sm.StateChanged += Handler;
try
{
if (sm.State == target)
{
return;
}
using var cts = new CancellationTokenSource(timeoutMs);
using (cts.Token.Register(() => tcs.TrySetCanceled()))
{
await tcs.Task.ConfigureAwait(false);
}
}
finally
{
sm.StateChanged -= Handler;
}
}
///
/// Attaches a recorder that captures all state transitions.
///
private static StateTransitionRecorder AttachRecorder(
ConnectionStateMachine sm)
{
var recorder = new StateTransitionRecorder();
sm.StateChanged += recorder.Handler;
return recorder;
}
///
/// Records all state transitions for later assertion.
///
private sealed class StateTransitionRecorder
{
private readonly List m_transitions = [];
private readonly TaskCompletionSource m_done = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private ConnectionState? m_waitTarget;
public void Handler(
object? sender, ConnectionStateChangedEventArgs e)
{
lock (m_transitions)
{
m_transitions.Add(e);
if (m_waitTarget.HasValue &&
e.NewState == m_waitTarget.Value)
{
m_done.TrySetResult(true);
}
}
}
public async Task WaitForStateAsync(
ConnectionState target,
int timeoutMs = 5000)
{
lock (m_transitions)
{
if (m_transitions.Any(t => t.NewState == target))
{
return;
}
m_waitTarget = target;
}
using var cts = new CancellationTokenSource(timeoutMs);
using (cts.Token.Register(
() => m_done.TrySetCanceled()))
{
await m_done.Task.ConfigureAwait(false);
}
}
public List GetTransitions()
{
lock (m_transitions)
{
return [.. m_transitions];
}
}
public bool HasVisited(ConnectionState state)
{
lock (m_transitions)
{
return m_transitions.Any(
t => t.NewState == state);
}
}
}
///
/// A reconnect policy test double that records the server-provided
/// retry-after passed to
/// and stops retrying
/// once one is observed so the machine transitions deterministically.
///
private sealed class CapturingReconnectPolicy : IReconnectPolicy
{
public ConcurrentQueue CapturedRetryAfter { get; } = new();
public TimeSpan? GetNextDelay(int attempt, CancellationToken ct = default)
{
return TimeSpan.FromMilliseconds(5);
}
public bool TryGetNextDelay(
int attempt,
StatusCode lastStatus,
TimeSpan? serverRetryAfter,
out TimeSpan? delay,
CancellationToken ct = default)
{
CapturedRetryAfter.Enqueue(serverRetryAfter);
if (serverRetryAfter.HasValue)
{
delay = null;
return true;
}
delay = TimeSpan.FromMilliseconds(5);
return true;
}
public void Reset()
{
}
}
[Test]
public void InitialStateIsDisconnected()
{
ConnectionStateMachine sm = CreateMachine();
Assert.That(
sm.State,
Is.EqualTo(ConnectionState.Disconnected));
Assert.That(sm.IsConnected, Is.False);
}
[Test]
public async Task ExternalStateCallbackDoesNotHoldStateLockAsync()
{
await using ConnectionStateMachine machine = CreateMachine();
machine.ConnectAsync = _ => Task.FromResult(ServiceResult.Good);
machine.Start();
machine.RequestConnect();
await machine.WaitForConnectedAsync(CancellationToken.None).ConfigureAwait(false);
using var closeEntered = new ManualResetEventSlim();
var callbackReleased = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Task close = Task.CompletedTask;
machine.StateChanged += (_, change) =>
{
if (change.NewState != ConnectionState.Reconnecting)
{
return;
}
close = Task.Run(() =>
{
machine.RequestClose();
closeEntered.Set();
});
callbackReleased.TrySetResult(closeEntered.Wait(TimeSpan.FromSeconds(2)));
};
machine.TriggerReconnect();
Assert.That(await callbackReleased.Task.ConfigureAwait(false), Is.True,
"An external state callback must not prevent another thread from requesting close.");
await close.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
await machine.WaitForClosedAsync(CancellationToken.None).AsTask()
.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
[Test]
public async Task ReentrantStateNotificationsKeepTransitionOrderAsync()
{
await using ConnectionStateMachine machine = CreateMachine();
machine.ConnectAsync = _ => Task.FromResult(ServiceResult.Good);
var observed = new ConcurrentQueue();
var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
machine.StateChanged += (_, change) =>
{
if (change.NewState == ConnectionState.Connected)
{
machine.RequestClose();
}
};
machine.StateChanged += (_, change) =>
{
observed.Enqueue(change.NewState);
if (change.NewState == ConnectionState.Closed)
{
closed.TrySetResult(true);
}
};
machine.Start();
machine.RequestConnect();
await closed.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
Assert.That(observed, Is.EqualTo(
[
ConnectionState.Connecting, ConnectionState.Connected,
ConnectionState.Closing, ConnectionState.Closed
]));
}
[Test]
public async Task StartTransitionsToConnecting()
{
await using ConnectionStateMachine sm = CreateMachine();
var connectTcs = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
sm.ConnectAsync = _ => connectTcs.Task;
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connecting)
.ConfigureAwait(false);
Assert.That(
sm.State,
Is.EqualTo(ConnectionState.Connecting));
connectTcs.SetResult(ServiceResult.Good);
}
[Test]
public async Task SuccessfulConnectTransitionsToConnected()
{
await using ConnectionStateMachine sm = CreateMachine();
sm.ConnectAsync = _ =>
Task.FromResult(ServiceResult.Good);
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
Assert.That(
sm.State,
Is.EqualTo(ConnectionState.Connected));
Assert.That(sm.IsConnected, Is.True);
}
[Test]
public async Task FailedConnectTransitionsToReconnecting()
{
await using ConnectionStateMachine sm = CreateMachine();
sm.ConnectAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadConnectionClosed));
sm.ReconnectAsync = async ct =>
{
await Task.Delay(Timeout.Infinite, ct)
.ConfigureAwait(false);
return ServiceResult.Good;
};
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(
sm, ConnectionState.Reconnecting)
.ConfigureAwait(false);
Assert.That(
sm.State,
Is.EqualTo(ConnectionState.Reconnecting));
}
[Test]
public async Task TriggerReconnectFromConnectedTransitionsToReconnecting()
{
await using ConnectionStateMachine sm = CreateMachine();
sm.ConnectAsync = _ =>
Task.FromResult(ServiceResult.Good);
sm.ReconnectAsync = async ct =>
{
await Task.Delay(Timeout.Infinite, ct)
.ConfigureAwait(false);
return ServiceResult.Good;
};
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
sm.TriggerReconnect();
await WaitForStateAsync(
sm, ConnectionState.Reconnecting)
.ConfigureAwait(false);
Assert.That(
sm.State,
Is.EqualTo(ConnectionState.Reconnecting));
}
[Test]
public async Task RequestCloseTransitionsToClosing()
{
await using ConnectionStateMachine sm = CreateMachine();
var closeTcs = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
sm.ConnectAsync = _ =>
Task.FromResult(ServiceResult.Good);
sm.CloseSessionAsync = _ => closeTcs.Task;
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
StateTransitionRecorder recorder = AttachRecorder(sm);
sm.RequestClose();
await recorder.WaitForStateAsync(ConnectionState.Closing)
.ConfigureAwait(false);
Assert.That(
recorder.HasVisited(ConnectionState.Closing),
Is.True);
closeTcs.SetResult(true);
}
[Test]
public async Task RequestCloseCancelsReconnectAttemptInFlight()
{
await using ConnectionStateMachine sm = CreateMachine();
var attemptStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var attemptCancelled = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var closed = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
sm.ConnectAsync = _ => Task.FromResult(ServiceResult.Good);
// Models a peer that accepts the connection but never answers: the
// attempt only ends when its token is cancelled.
sm.ReconnectAsync = async ct =>
{
attemptStarted.TrySetResult(true);
try
{
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
attemptCancelled.TrySetResult(true);
throw;
}
return ServiceResult.Good;
};
sm.CloseSessionAsync = _ =>
{
closed.TrySetResult(true);
return Task.CompletedTask;
};
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
sm.TriggerReconnect();
await attemptStarted.Task.WaitAsync(TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
sm.RequestClose();
await attemptCancelled.Task.WaitAsync(TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
await closed.Task.WaitAsync(TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
using var cts = new CancellationTokenSource(
TimeSpan.FromSeconds(10));
await sm.WaitForClosedAsync(cts.Token).ConfigureAwait(false);
Assert.That(sm.State, Is.EqualTo(ConnectionState.Closed));
}
[Test]
public async Task RequestCloseDuringConnectDoesNotReportConnected()
{
await using ConnectionStateMachine sm = CreateMachine();
var attemptStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
sm.ConnectAsync = async ct =>
{
attemptStarted.TrySetResult(true);
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
return ServiceResult.Good;
};
sm.CloseSessionAsync = _ => Task.CompletedTask;
sm.Start();
sm.RequestConnect();
await attemptStarted.Task.WaitAsync(TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
sm.RequestClose();
using var cts = new CancellationTokenSource(
TimeSpan.FromSeconds(10));
await sm.WaitForClosedAsync(cts.Token).ConfigureAwait(false);
Assert.That(sm.State, Is.EqualTo(ConnectionState.Closed));
Assert.That(sm.IsConnected, Is.False);
}
[Test]
public async Task DisposeTransitionsToClosed()
{
ConnectionStateMachine sm = CreateMachine();
sm.ConnectAsync = _ =>
Task.FromResult(ServiceResult.Good);
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
await sm.DisposeAsync().ConfigureAwait(false);
Assert.That(
sm.State,
Is.EqualTo(ConnectionState.Closed));
}
[Test]
public async Task StateChangedEventFires()
{
await using ConnectionStateMachine sm = CreateMachine();
StateTransitionRecorder recorder = AttachRecorder(sm);
sm.ConnectAsync = _ =>
Task.FromResult(ServiceResult.Good);
sm.Start();
sm.RequestConnect();
await recorder.WaitForStateAsync(
ConnectionState.Connected)
.ConfigureAwait(false);
List transitions = recorder.GetTransitions();
Assert.That(
transitions, Has.Count.GreaterThanOrEqualTo(2));
Assert.That(
transitions[0].PreviousState,
Is.EqualTo(ConnectionState.Disconnected));
Assert.That(
transitions[0].NewState,
Is.EqualTo(ConnectionState.Connecting));
Assert.That(
transitions[1].PreviousState,
Is.EqualTo(ConnectionState.Connecting));
Assert.That(
transitions[1].NewState,
Is.EqualTo(ConnectionState.Connected));
}
[TestCase("success")]
[TestCase("failure")]
[TestCase("close")]
[TestCase("handshake-failure")]
public async Task RecoveryServicesOpenOnlyAfterHandshakeAndCompletionStillSettlesAsync(string outcome)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await using ConnectionStateMachine machine = CreateMachine(new ReconnectPolicy
{
InitialDelay = TimeSpan.Zero,
MaxRetries = 1,
JitterFactor = 0
});
var handshake = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var restoring = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
machine.ConnectAsync = _ => Task.FromResult(ServiceResult.Good);
machine.ReconnectAsync = _ => handshake.Task;
machine.FailoverAsync = _ => Task.FromResult(new ServiceResult(StatusCodes.BadNotSupported));
machine.Start();
machine.RequestConnect();
await machine.WaitForConnectedAsync(timeout.Token).ConfigureAwait(false);
machine.CompleteRecoveryAsync = ct =>
{
restoring.TrySetResult(true);
return finish.Task.WaitAsync(ct);
};
try
{
machine.TriggerReconnect();
Task available = machine.WaitForServiceAvailabilityAsync(timeout.Token).AsTask();
Task connected = machine.WaitForConnectedAsync(timeout.Token).AsTask();
Assert.That(available.IsCompleted, Is.False);
if (outcome == "handshake-failure")
{
handshake.TrySetResult(new ServiceResult(StatusCodes.BadSecurityChecksFailed));
ServiceResultException exception = Assert.ThrowsAsync(
async () => await connected.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed));
ServiceResultException serviceError = Assert.ThrowsAsync(
async () => await available.ConfigureAwait(false));
Assert.That(serviceError.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed));
Assert.That(restoring.Task.IsCompleted, Is.False);
return;
}
handshake.TrySetResult(ServiceResult.Good);
await restoring.Task.WaitAsync(timeout.Token).ConfigureAwait(false);
await available.ConfigureAwait(false);
Assert.That(connected.IsCompleted, Is.False);
Assert.That(machine.State, Is.EqualTo(ConnectionState.Reconnecting));
if (outcome == "close")
{
machine.RequestClose();
await machine.WaitForClosedAsync(timeout.Token).ConfigureAwait(false);
Assert.That(async () => await connected.ConfigureAwait(false),
Throws.InstanceOf());
Assert.That(async () => await machine.WaitForServiceAvailabilityAsync(timeout.Token)
.ConfigureAwait(false), Throws.InstanceOf());
}
else if (outcome == "failure")
{
finish.TrySetResult(new ServiceResult(StatusCodes.BadResourceUnavailable));
ServiceResultException exception = Assert.ThrowsAsync(
async () => await connected.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadResourceUnavailable));
Assert.That(machine.State, Is.EqualTo(ConnectionState.Disconnected));
ServiceResultException serviceError = Assert.ThrowsAsync(
async () => await machine.WaitForServiceAvailabilityAsync(timeout.Token).ConfigureAwait(false));
Assert.That(serviceError.StatusCode, Is.EqualTo(StatusCodes.BadResourceUnavailable));
}
else
{
finish.TrySetResult(ServiceResult.Good);
await connected.ConfigureAwait(false);
Assert.That(machine.State, Is.EqualTo(ConnectionState.Connected));
}
}
finally
{
handshake.TrySetResult(ServiceResult.Good);
finish.TrySetResult(ServiceResult.Good);
}
}
[Test]
public async Task WaitForConnectedAsyncReturnsWhenConnected()
{
await using ConnectionStateMachine sm = CreateMachine();
sm.ConnectAsync = _ =>
Task.FromResult(ServiceResult.Good);
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
using var cts = new CancellationTokenSource(
TimeSpan.FromSeconds(2));
await sm.WaitForConnectedAsync(cts.Token)
.ConfigureAwait(false);
Assert.That(sm.IsConnected, Is.True);
}
[Test]
public async Task WaitForConnectedAsyncBlocksWhenDisconnected()
{
await using ConnectionStateMachine sm = CreateMachine();
var connectTcs = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
sm.ConnectAsync = _ => connectTcs.Task;
sm.Start();
sm.RequestConnect();
using var cts = new CancellationTokenSource(
TimeSpan.FromMilliseconds(200));
ValueTask waitTask = sm.WaitForConnectedAsync(cts.Token);
Assert.That(waitTask.IsCompleted, Is.False);
bool cancelled = false;
try
{
await waitTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
cancelled = true;
}
Assert.That(cancelled, Is.True,
"WaitForConnectedAsync should cancel when " +
"token fires before connected");
connectTcs.SetResult(ServiceResult.Good);
}
[Test]
public async Task ReconnectUsesBackoffDelays()
{
// Use 100 ms initial delay so the recorded gaps are dominated
// by the configured exponential backoff rather than by
// scheduler jitter. On constrained runners (notably macOS
// hosted agents) a 10 ms initial delay produces gaps in the
// 20–120 ms range where ~10 ms thread-pool scheduler noise
// can violate the `gap2 >= gap1 * 0.8` invariant the test is
// checking. The 100 ms / 200 ms / 400 ms sequence keeps the
// total test time bounded (~700 ms) while moving the
// expected gaps well above scheduler precision.
var policy = new ReconnectPolicy
{
Strategy = BackoffStrategy.Exponential,
InitialDelay = TimeSpan.FromMilliseconds(100),
MaxDelay = TimeSpan.FromSeconds(5),
JitterFactor = 0.0,
MaxRetries = 5
};
await using ConnectionStateMachine sm = CreateMachine(policy);
int attempt = 0;
var attemptTimes = new List();
sm.ConnectAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadConnectionClosed));
sm.ReconnectAsync = _ =>
{
Interlocked.Increment(ref attempt);
lock (attemptTimes)
{
attemptTimes.Add(DateTime.UtcNow);
}
return Task.FromResult(
new ServiceResult(StatusCodes.BadConnectionClosed));
};
sm.FailoverAsync = _ =>
Task.FromResult(ServiceResult.Good);
StateTransitionRecorder recorder = AttachRecorder(sm);
sm.Start();
sm.RequestConnect();
await recorder.WaitForStateAsync(
ConnectionState.Connected, 10000)
.ConfigureAwait(false);
lock (attemptTimes)
{
Assert.That(
attemptTimes,
Has.Count.GreaterThanOrEqualTo(2),
"Expected multiple reconnect attempts");
if (attemptTimes.Count >= 3)
{
TimeSpan gap1 = attemptTimes[1] - attemptTimes[0];
TimeSpan gap2 = attemptTimes[2] - attemptTimes[1];
Assert.That(
gap2.TotalMilliseconds,
Is.GreaterThanOrEqualTo(
gap1.TotalMilliseconds * 0.8),
"Exponential backoff should increase " +
"delays");
}
}
}
[Test]
public async Task ReconnectStopsAfterMaxRetries()
{
var policy = new ReconnectPolicy
{
Strategy = BackoffStrategy.Constant,
InitialDelay = TimeSpan.FromMilliseconds(5),
MaxRetries = 3,
JitterFactor = 0.0
};
await using ConnectionStateMachine sm = CreateMachine(policy);
sm.ConnectAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadConnectionClosed));
sm.ReconnectAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadConnectionClosed));
sm.FailoverAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadNotSupported));
StateTransitionRecorder recorder = AttachRecorder(sm);
sm.Start();
sm.RequestConnect();
// Failover → Disconnected can be fast; use recorder
// to check that Failover was visited.
await recorder.WaitForStateAsync(
ConnectionState.Disconnected, 5000)
.ConfigureAwait(false);
Assert.That(
recorder.HasVisited(ConnectionState.Failover),
Is.True,
"Should have visited Failover state");
Assert.That(
sm.State,
Is.EqualTo(ConnectionState.Disconnected));
}
[Test]
public async Task WaitForConnectedAsyncThrowsOriginalErrorWhenConnectGivesUp()
{
var policy = new ReconnectPolicy
{
Strategy = BackoffStrategy.Constant,
InitialDelay = TimeSpan.FromMilliseconds(5),
MaxRetries = 2,
JitterFactor = 0.0
};
await using ConnectionStateMachine sm = CreateMachine(policy);
sm.ConnectAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadCertificateUntrusted));
sm.ReconnectAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadCertificateUntrusted));
// No redundant server: failover reports BadNotSupported, which must
// not mask the error that actually prevented the connection.
sm.FailoverAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadNotSupported));
sm.Start();
sm.RequestConnect();
using var cts = new CancellationTokenSource(
TimeSpan.FromSeconds(10));
ServiceResultException exception = Assert.ThrowsAsync(
async () => await sm.WaitForConnectedAsync(cts.Token)
.ConfigureAwait(false));
Assert.That(
exception.StatusCode,
Is.EqualTo(StatusCodes.BadCertificateUntrusted));
Assert.That(
sm.State,
Is.EqualTo(ConnectionState.Disconnected));
Assert.That(sm.IsConnected, Is.False);
}
[Test]
public async Task WaitForConnectedAsyncThrowsWhenClosedWhileConnecting()
{
await using ConnectionStateMachine sm = CreateMachine();
var connectTcs = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
sm.ConnectAsync = _ => connectTcs.Task;
sm.Start();
sm.RequestConnect();
using var cts = new CancellationTokenSource(
TimeSpan.FromSeconds(10));
ValueTask waitTask = sm.WaitForConnectedAsync(cts.Token);
Assert.That(waitTask.IsCompleted, Is.False);
sm.RequestClose();
ServiceResultException exception = Assert.ThrowsAsync(
async () => await waitTask.ConfigureAwait(false));
Assert.That(
exception.StatusCode,
Is.EqualTo(StatusCodes.BadNotConnected));
connectTcs.SetResult(ServiceResult.Good);
}
[Test]
public async Task ReconnectHonorsRetryAfterFromAdditionalInfo()
{
var policy = new CapturingReconnectPolicy();
await using ConnectionStateMachine sm = new(policy, m_logger);
sm.ConnectAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadConnectionClosed));
sm.ReconnectAsync = _ => Task.FromResult(
new ServiceResult(
null,
StatusCodes.BadServerTooBusy,
new LocalizedText("The server is too busy."),
"RetryAfterMs=2000"));
sm.FailoverAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadNotSupported));
StateTransitionRecorder recorder = AttachRecorder(sm);
sm.Start();
sm.RequestConnect();
await recorder.WaitForStateAsync(
ConnectionState.Disconnected, 5000)
.ConfigureAwait(false);
Assert.That(
policy.CapturedRetryAfter.Any(r => r == TimeSpan.FromSeconds(2)),
Is.True,
"The retry-after carried in AdditionalInfo should reach the policy.");
}
[Test]
public async Task ReconnectHonorsRetryAfterFromLocalizedMessage()
{
var policy = new CapturingReconnectPolicy();
await using ConnectionStateMachine sm = new(policy, m_logger);
sm.ConnectAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadConnectionClosed));
// The hint is only in the localized message (as it would be for a
// transport-level UA-TCP Error reason), not in AdditionalInfo.
sm.ReconnectAsync = _ => Task.FromResult(
new ServiceResult(
null,
StatusCodes.BadServerTooBusy,
new LocalizedText(
"Error received from remote host: RetryAfterMs=2000"),
"server overloaded"));
sm.FailoverAsync = _ => Task.FromResult(
new ServiceResult(StatusCodes.BadNotSupported));
StateTransitionRecorder recorder = AttachRecorder(sm);
sm.Start();
sm.RequestConnect();
await recorder.WaitForStateAsync(
ConnectionState.Disconnected, 5000)
.ConfigureAwait(false);
Assert.That(
policy.CapturedRetryAfter.Any(r => r == TimeSpan.FromSeconds(2)),
Is.True,
"The retry-after carried in the localized message should reach the policy.");
}
[Test]
public async Task ReconnectPolicyResetAfterSuccess()
{
var mockPolicy = new Mock();
mockPolicy.Setup(p => p.GetNextDelay(
It.IsAny(),
It.IsAny()))
.Returns(TimeSpan.FromMilliseconds(5));
var sm = new ConnectionStateMachine(
mockPolicy.Object, m_logger);
await using (sm.ConfigureAwait(false))
{
int connectCallCount = 0;
sm.ConnectAsync = _ =>
{
int call = Interlocked.Increment(
ref connectCallCount);
if (call == 1)
{
return Task.FromResult(
new ServiceResult(
StatusCodes.BadConnectionClosed));
}
return Task.FromResult(ServiceResult.Good);
};
sm.ReconnectAsync = _ =>
Task.FromResult(ServiceResult.Good);
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
mockPolicy.Verify(
p => p.Reset(),
Times.AtLeastOnce,
"Policy should be reset after successful " +
"reconnect");
}
}
///
/// Work spawned by a worker step (keep-alive, publish loops) must not
/// pass as the worker once the step is over (L2-1).
///
[Test]
public async Task WorkSpawnedByWorkerStepLeavesWorkerFlowAsync()
{
ConnectionStateMachine sm = CreateMachine();
await using (sm.ConfigureAwait(false))
{
var gate = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
bool inWorkerDuringConnect = false;
Task? spawned = null;
sm.ConnectAsync = _ =>
{
inWorkerDuringConnect = sm.IsWorkerFlow;
spawned = Task.Run(async () =>
{
await gate.Task.ConfigureAwait(false);
for (int ii = 0; ii < 250 && sm.IsWorkerFlow; ii++)
{
await Task.Delay(20).ConfigureAwait(false);
}
return sm.IsWorkerFlow;
});
return Task.FromResult(ServiceResult.Good);
};
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
gate.SetResult(true);
Assert.That(inWorkerDuringConnect, Is.True);
Assert.That(await spawned!.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), Is.False,
"Spawned background work must not be treated as the worker after the step ended.");
}
}
///
/// A negative reconnect budget is rejected at construction instead of
/// crashing the worker on the first reconnect (L3-7).
///
[Test]
public void NegativeMaxTotalReconnectTimeIsRejected()
{
Assert.That(
() => new ConnectionStateMachine(
new ReconnectPolicy(),
m_logger,
TimeSpan.FromSeconds(-5)),
Throws.TypeOf());
}
///
/// A zero reconnect budget means unlimited and still reconnects (L3-7).
///
[Test]
public async Task ZeroMaxTotalReconnectTimeStillReconnectsAsync()
{
var sm = new ConnectionStateMachine(
new ReconnectPolicy
{
JitterFactor = 0.0,
Strategy = BackoffStrategy.Constant,
InitialDelay = TimeSpan.FromMilliseconds(10)
},
m_logger,
TimeSpan.Zero);
await using (sm.ConfigureAwait(false))
{
int reconnects = 0;
sm.ConnectAsync = _ => Task.FromResult(ServiceResult.Good);
sm.ReconnectAsync = _ =>
{
Interlocked.Increment(ref reconnects);
return Task.FromResult(ServiceResult.Good);
};
StateTransitionRecorder recorder = AttachRecorder(sm);
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
sm.TriggerReconnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
Assert.That(Volatile.Read(ref reconnects), Is.EqualTo(1));
Assert.That(recorder.HasVisited(ConnectionState.Failover), Is.False);
}
}
///
/// A close requested while the reconnect policy runs out must not be
/// overwritten by the transition to Failover (L3-4).
///
[Test]
public async Task CloseRacingPolicyExhaustionIsNotLostAsync()
{
ConnectionStateMachine? sm = null;
var mockPolicy = new Mock();
mockPolicy.Setup(p => p.GetNextDelay(
It.IsAny(),
It.IsAny()))
.Returns(() =>
{
// Close arrives right before the policy reports exhaustion.
sm!.RequestClose();
return (TimeSpan?)null;
});
sm = new ConnectionStateMachine(mockPolicy.Object, m_logger);
await using (sm.ConfigureAwait(false))
{
int closeCalls = 0;
sm.ConnectAsync = _ => Task.FromResult(ServiceResult.Good);
sm.CloseSessionAsync = _ =>
{
Interlocked.Increment(ref closeCalls);
return Task.CompletedTask;
};
StateTransitionRecorder recorder = AttachRecorder(sm);
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
sm.TriggerReconnect();
await sm.WaitForClosedAsync(CancellationToken.None).AsTask()
.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
Assert.That(recorder.HasVisited(ConnectionState.Failover), Is.False);
Assert.That(Volatile.Read(ref closeCalls), Is.EqualTo(1),
"The clean close must run when the close raced the policy exhaustion.");
}
}
[Test]
public async Task WaitForConnectedAsyncCompletesAfterReconnect()
{
await using ConnectionStateMachine sm = CreateMachine();
var connectTcs = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
sm.ConnectAsync = _ => connectTcs.Task;
sm.Start();
sm.RequestConnect();
using var cts = new CancellationTokenSource(
TimeSpan.FromSeconds(5));
ValueTask waitTask = sm.WaitForConnectedAsync(cts.Token);
Assert.That(waitTask.IsCompleted, Is.False);
connectTcs.SetResult(ServiceResult.Good);
await waitTask.ConfigureAwait(false);
Assert.That(sm.IsConnected, Is.True);
}
[Test]
public async Task WaitForConnectedBlocksDuringReconnect()
{
ConnectionStateMachine sm = CreateMachine();
int reconnectCount = 0;
var reconnectTcs = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
sm.ConnectAsync = _ =>
Task.FromResult(ServiceResult.Good);
sm.ReconnectAsync = ct =>
{
int count = Interlocked.Increment(
ref reconnectCount);
if (count == 1)
{
return reconnectTcs.Task.WaitAsync(ct);
}
return Task.FromResult(ServiceResult.Good);
};
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
sm.TriggerReconnect();
await WaitForStateAsync(
sm, ConnectionState.Reconnecting)
.ConfigureAwait(false);
using var cts = new CancellationTokenSource(
TimeSpan.FromMilliseconds(200));
ValueTask waitTask = sm.WaitForConnectedAsync(cts.Token);
Assert.That(waitTask.IsCompleted, Is.False);
bool cancelled = false;
try
{
await waitTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
cancelled = true;
}
Assert.That(cancelled, Is.True,
"WaitForConnectedAsync should block during " +
"reconnect");
reconnectTcs.SetResult(ServiceResult.Good);
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
await sm.DisposeAsync().ConfigureAwait(false);
}
[Test]
public async Task DoubleDisposeDoesNotThrow()
{
ConnectionStateMachine sm = CreateMachine();
sm.Start();
await sm.DisposeAsync().ConfigureAwait(false);
await sm.DisposeAsync().ConfigureAwait(false);
Assert.That(
sm.State,
Is.EqualTo(ConnectionState.Closed));
}
[Test]
public async Task EstablishedSessionStartsAnotherCycleAfterExhaustionBackoffAsync()
{
var time = new FakeTimeProvider();
var policy = new ReconnectPolicy
{
InitialDelay = TimeSpan.Zero,
MaxDelay = TimeSpan.FromSeconds(30),
MaxRetries = 1,
JitterFactor = 0
};
await using var machine = new ConnectionStateMachine(policy, m_logger, timeProvider: time);
machine.ConnectAsync = _ => Task.FromResult(ServiceResult.Good);
int attempts = 0;
machine.ReconnectAsync = _ => Task.FromResult(++attempts == 1
? new ServiceResult(StatusCodes.BadConnectionClosed)
: ServiceResult.Good);
machine.FailoverAsync = _ => Task.FromResult(new ServiceResult(StatusCodes.BadNotSupported));
var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
machine.StateChanged += (_, change) =>
{
if (change.NewState == ConnectionState.Disconnected)
{
disconnected.TrySetResult(true);
}
};
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
machine.Start();
machine.RequestConnect();
await machine.WaitForConnectedAsync(timeout.Token).ConfigureAwait(false);
machine.TriggerReconnect();
await disconnected.Task.WaitAsync(timeout.Token).ConfigureAwait(false);
Assert.ThrowsAsync(
async () => await machine.WaitForConnectedAsync(timeout.Token).ConfigureAwait(false));
time.Advance(TimeSpan.FromMinutes(1));
await machine.WaitForConnectedAsync(timeout.Token).ConfigureAwait(false);
Assert.That(machine.State, Is.EqualTo(ConnectionState.Connected));
Assert.That(attempts, Is.EqualTo(2));
}
[Test]
public async Task TriggerReconnectWhenDisconnectedIsNoOp()
{
await using ConnectionStateMachine sm = CreateMachine();
sm.TriggerReconnect();
Assert.That(
sm.State,
Is.EqualTo(ConnectionState.Disconnected));
}
[Test]
public void RequestCloseWhenAlreadyClosedIsNoOp()
{
ConnectionStateMachine sm = CreateMachine();
sm.RequestClose();
Assert.That(
sm.State,
Is.AnyOf(
ConnectionState.Closing,
ConnectionState.Disconnected));
}
[Test]
public async Task StartIsIdempotent()
{
await using ConnectionStateMachine sm = CreateMachine();
sm.ConnectAsync = _ =>
Task.FromResult(ServiceResult.Good);
sm.Start();
sm.Start();
sm.RequestConnect();
await WaitForStateAsync(sm, ConnectionState.Connected)
.ConfigureAwait(false);
Assert.That(sm.IsConnected, Is.True);
}
}
}