/* ========================================================================
* 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.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
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);
}
}
}
[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 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 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));
}
[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 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");
}
}
[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 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);
}
}
}