feat: add resilient reconnect and catch-up replay
This commit is contained in:
@@ -34,3 +34,7 @@ Optional tag variables (tests run only for the tags provided):
|
||||
|
||||
- If integration settings are missing, tests return immediately and do not perform network calls.
|
||||
- These tests are intended as a live harness, not deterministic CI tests.
|
||||
- The client runtime now uses a background receive loop with automatic reconnect, durable subscription replay, and optional best-effort latest-value catch-up replay after reconnect.
|
||||
- Reconnect timing is policy-based and jittered by default.
|
||||
- These live tests still need validation against a real AVEVA server that allows legacy or mixed-mode SuiteLink traffic.
|
||||
- Writes are intentionally rejected while the client is in `Reconnecting`.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using SuiteLink.Client.Internal;
|
||||
|
||||
namespace SuiteLink.Client.Tests.Internal;
|
||||
|
||||
public sealed class SuiteLinkRetryDelayCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetDelay_UsesImmediateThenExponentialCap()
|
||||
{
|
||||
var policy = new SuiteLinkRetryPolicy(
|
||||
initialDelay: TimeSpan.FromSeconds(1),
|
||||
multiplier: 2.0,
|
||||
maxDelay: TimeSpan.FromSeconds(30),
|
||||
useJitter: false);
|
||||
|
||||
Assert.Equal(TimeSpan.Zero, SuiteLinkRetryDelayCalculator.GetDelay(policy, 0));
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), SuiteLinkRetryDelayCalculator.GetDelay(policy, 1));
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), SuiteLinkRetryDelayCalculator.GetDelay(policy, 2));
|
||||
Assert.Equal(TimeSpan.FromSeconds(4), SuiteLinkRetryDelayCalculator.GetDelay(policy, 3));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1, 2.0, 30)]
|
||||
[InlineData(1, 0.0, 30)]
|
||||
[InlineData(1, -1.0, 30)]
|
||||
[InlineData(1, 2.0, -1)]
|
||||
public void RetryPolicy_InvalidArguments_Throw(
|
||||
int initialDelaySeconds,
|
||||
double multiplier,
|
||||
int maxDelaySeconds)
|
||||
{
|
||||
Assert.ThrowsAny<ArgumentOutOfRangeException>(() => new SuiteLinkRetryPolicy(
|
||||
initialDelay: TimeSpan.FromSeconds(initialDelaySeconds),
|
||||
multiplier: multiplier,
|
||||
maxDelay: TimeSpan.FromSeconds(maxDelaySeconds),
|
||||
useJitter: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDelay_WithJitterEnabled_StaysWithinCap()
|
||||
{
|
||||
var policy = new SuiteLinkRetryPolicy(
|
||||
initialDelay: TimeSpan.FromSeconds(2),
|
||||
multiplier: 2.0,
|
||||
maxDelay: TimeSpan.FromSeconds(10),
|
||||
useJitter: true);
|
||||
|
||||
var delay = SuiteLinkRetryDelayCalculator.GetDelay(policy, 3, () => 0.5);
|
||||
|
||||
Assert.InRange(delay, TimeSpan.Zero, TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
@@ -169,12 +169,56 @@ public sealed class SuiteLinkSessionTests
|
||||
Assert.Equal("callback failure", callbackException.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDispatchUpdate_WithExplicitSource_UsesProvidedSource()
|
||||
{
|
||||
var session = new SuiteLinkSession();
|
||||
SuiteLinkTagUpdate? callbackUpdate = null;
|
||||
|
||||
session.RegisterSubscription("Pump001.Run", 0x1234, update => callbackUpdate = update);
|
||||
|
||||
var decoded = new DecodedUpdate(
|
||||
TagId: 0x1234,
|
||||
Quality: 0x00C0,
|
||||
ElapsedMilliseconds: 10,
|
||||
Value: SuiteLinkValue.FromBoolean(true));
|
||||
|
||||
var dispatched = session.TryDispatchUpdate(
|
||||
decoded,
|
||||
DateTimeOffset.UtcNow,
|
||||
SuiteLinkUpdateSource.CatchUpReplay,
|
||||
out var dispatchedUpdate,
|
||||
out _);
|
||||
|
||||
Assert.True(dispatched);
|
||||
Assert.NotNull(dispatchedUpdate);
|
||||
Assert.Equal(SuiteLinkUpdateSource.CatchUpReplay, dispatchedUpdate.Source);
|
||||
Assert.Equal(dispatchedUpdate, callbackUpdate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearSubscriptions_RemovesAllMappings()
|
||||
{
|
||||
var session = new SuiteLinkSession();
|
||||
|
||||
session.RegisterSubscription("Pump001.Run", 0x1234, _ => { });
|
||||
session.RegisterSubscription("Pump001.Speed", 0x5678, _ => { });
|
||||
|
||||
session.ClearSubscriptions();
|
||||
|
||||
Assert.False(session.TryGetTagId("Pump001.Run", out _));
|
||||
Assert.False(session.TryGetTagId("Pump001.Speed", out _));
|
||||
Assert.False(session.TryGetItemName(0x1234, out _));
|
||||
Assert.False(session.TryGetItemName(0x5678, out _));
|
||||
Assert.Equal(0, session.SubscriptionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetState_InvalidTransition_ThrowsInvalidOperationException()
|
||||
{
|
||||
var session = new SuiteLinkSession();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => session.SetState(SuiteLinkSessionState.SessionConnected));
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => session.SetState(SuiteLinkSessionState.Ready));
|
||||
|
||||
Assert.Contains("Invalid state transition", ex.Message);
|
||||
Assert.Equal(SuiteLinkSessionState.Disconnected, session.State);
|
||||
@@ -191,4 +235,17 @@ public sealed class SuiteLinkSessionTests
|
||||
Assert.False(session.TryTransitionState(SuiteLinkSessionState.Disconnected, SuiteLinkSessionState.HandshakeComplete));
|
||||
Assert.Equal(SuiteLinkSessionState.TcpConnected, session.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetState_ReconnectAttemptStartupFailure_CanReturnToReconnecting()
|
||||
{
|
||||
var session = new SuiteLinkSession();
|
||||
|
||||
session.SetState(SuiteLinkSessionState.TcpConnected);
|
||||
session.SetState(SuiteLinkSessionState.HandshakeComplete);
|
||||
session.SetState(SuiteLinkSessionState.ConnectSent);
|
||||
session.SetState(SuiteLinkSessionState.Reconnecting);
|
||||
|
||||
Assert.Equal(SuiteLinkSessionState.Reconnecting, session.State);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace SuiteLink.Client.Tests;
|
||||
public sealed class SuiteLinkClientConnectionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConnectAsync_SendsHandshakeThenConnect_ButDoesNotReportReadyYet()
|
||||
public async Task ConnectAsync_SendsHandshakeThenConnect_AndTransitionsToReadyWhenRuntimeLoopStarts()
|
||||
{
|
||||
var handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
|
||||
var transport = new FakeTransport([handshakeAckFrame[..4], handshakeAckFrame[4..]]);
|
||||
@@ -15,7 +15,7 @@ public sealed class SuiteLinkClientConnectionTests
|
||||
|
||||
await client.ConnectAsync(options);
|
||||
|
||||
Assert.False(client.IsConnected);
|
||||
Assert.True(client.IsConnected);
|
||||
Assert.Equal(2, transport.SentBuffers.Count);
|
||||
Assert.Equal(
|
||||
SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake(
|
||||
|
||||
669
tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs
Normal file
669
tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs
Normal file
@@ -0,0 +1,669 @@
|
||||
using System.Net.Sockets;
|
||||
using SuiteLink.Client.Internal;
|
||||
using SuiteLink.Client.Protocol;
|
||||
using SuiteLink.Client.Transport;
|
||||
|
||||
namespace SuiteLink.Client.Tests;
|
||||
|
||||
public sealed class SuiteLinkClientReconnectTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Reconnect_UsesConfiguredRetryPolicy()
|
||||
{
|
||||
var observedDelays = new List<TimeSpan>();
|
||||
var capturedSchedule = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var syncRoot = new object();
|
||||
|
||||
Task CaptureDelayAsync(TimeSpan delay, CancellationToken _)
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
observedDelays.Add(delay);
|
||||
if (observedDelays.Count >= 5)
|
||||
{
|
||||
capturedSchedule.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var transport = new RuntimeDisconnectFakeTransport(DisconnectBehavior.ReturnEof)
|
||||
.WithFrame(BuildHandshakeAckFrame())
|
||||
.WithFrame(BuildAdviseAckFrame(1));
|
||||
var client = new SuiteLinkClient(transport, ownsTransport: false, delayAsync: CaptureDelayAsync);
|
||||
|
||||
await client.ConnectAsync(CreateOptions(runtime: new SuiteLinkRuntimeOptions(
|
||||
retryPolicy: new SuiteLinkRetryPolicy(
|
||||
initialDelay: TimeSpan.FromSeconds(3),
|
||||
multiplier: 3.0,
|
||||
maxDelay: TimeSpan.FromSeconds(20),
|
||||
useJitter: false),
|
||||
catchUpPolicy: SuiteLinkCatchUpPolicy.None,
|
||||
catchUpTimeout: TimeSpan.FromSeconds(2))));
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
_ = await capturedSchedule.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
TimeSpan[] firstFiveObserved;
|
||||
lock (syncRoot)
|
||||
{
|
||||
firstFiveObserved =
|
||||
[
|
||||
observedDelays[0],
|
||||
observedDelays[1],
|
||||
observedDelays[2],
|
||||
observedDelays[3],
|
||||
observedDelays[4]
|
||||
];
|
||||
}
|
||||
|
||||
Assert.Equal(TimeSpan.Zero, firstFiveObserved[0]);
|
||||
Assert.Equal(TimeSpan.FromSeconds(3), firstFiveObserved[1]);
|
||||
Assert.Equal(TimeSpan.FromSeconds(9), firstFiveObserved[2]);
|
||||
Assert.Equal(TimeSpan.FromSeconds(20), firstFiveObserved[3]);
|
||||
Assert.Equal(TimeSpan.FromSeconds(20), firstFiveObserved[4]);
|
||||
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveLoop_Eof_TransitionsToReconnecting()
|
||||
{
|
||||
var transport = new RuntimeDisconnectFakeTransport(DisconnectBehavior.ReturnEof)
|
||||
.WithFrame(BuildHandshakeAckFrame())
|
||||
.WithFrame(BuildAdviseAckFrame(1));
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
|
||||
await AssertStateEventuallyAsync(client, SuiteLinkSessionState.Reconnecting);
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveLoop_ReceiveIOException_TransitionsToReconnecting()
|
||||
{
|
||||
var transport = new RuntimeDisconnectFakeTransport(DisconnectBehavior.ThrowIoException)
|
||||
.WithFrame(BuildHandshakeAckFrame())
|
||||
.WithFrame(BuildAdviseAckFrame(1));
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
|
||||
await AssertStateEventuallyAsync(client, SuiteLinkSessionState.Reconnecting);
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveLoop_ReceiveSocketException_TransitionsToReconnecting()
|
||||
{
|
||||
var transport = new RuntimeDisconnectFakeTransport(DisconnectBehavior.ThrowSocketException)
|
||||
.WithFrame(BuildHandshakeAckFrame())
|
||||
.WithFrame(BuildAdviseAckFrame(1));
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
|
||||
await AssertStateEventuallyAsync(client, SuiteLinkSessionState.Reconnecting);
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReceiveLoop_PartialFrameThenEof_TransitionsToReconnecting()
|
||||
{
|
||||
var updateFrame = BuildBooleanUpdateFrame(1, true);
|
||||
var transport = new RuntimeDisconnectFakeTransport(DisconnectBehavior.ReturnEof)
|
||||
.WithFrame(BuildHandshakeAckFrame())
|
||||
.WithFrame(BuildAdviseAckFrame(1))
|
||||
.WithChunk(updateFrame.AsSpan(0, 5).ToArray());
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
|
||||
await AssertStateEventuallyAsync(client, SuiteLinkSessionState.Reconnecting);
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, DisconnectBehavior.ReturnEof)]
|
||||
[InlineData(true, DisconnectBehavior.ThrowIoException)]
|
||||
[InlineData(false, DisconnectBehavior.ReturnEof)]
|
||||
[InlineData(false, DisconnectBehavior.ThrowIoException)]
|
||||
public async Task CloseOperations_RacingRuntimeDisconnect_EndInDisconnectedState(
|
||||
bool useDisposeAsync,
|
||||
DisconnectBehavior behavior)
|
||||
{
|
||||
var runtimeReceiveEntered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var allowDisconnectSignal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var transport = new RuntimeDisconnectFakeTransport(behavior)
|
||||
.WithFrame(BuildHandshakeAckFrame())
|
||||
.WithFrame(BuildAdviseAckFrame(1));
|
||||
transport.RuntimeReceiveEntered = runtimeReceiveEntered;
|
||||
transport.AllowDisconnectSignal = allowDisconnectSignal.Task;
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
|
||||
_ = await runtimeReceiveEntered.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var closeTask = useDisposeAsync
|
||||
? client.DisposeAsync().AsTask()
|
||||
: client.DisconnectAsync();
|
||||
|
||||
allowDisconnectSignal.TrySetResult(true);
|
||||
await closeTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(SuiteLinkSessionState.Disconnected, client.DebugState);
|
||||
Assert.False(client.IsConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyWithNoSubscriptions_DoesNotProbeTransportLiveness_AndRemainsReady()
|
||||
{
|
||||
var transport = new RuntimeDisconnectFakeTransport(DisconnectBehavior.ReturnEof)
|
||||
.WithFrame(BuildHandshakeAckFrame());
|
||||
var client = new SuiteLinkClient(transport);
|
||||
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
await Task.Delay(250);
|
||||
|
||||
Assert.Equal(SuiteLinkSessionState.Ready, client.DebugState);
|
||||
Assert.Equal(0, transport.RuntimeReceiveCallCount);
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectAsync_CancelsPendingReconnectDelay_AndEndsDisconnected()
|
||||
{
|
||||
var reconnectDelayStarted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var reconnectDelayCanceled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
if (delay == TimeSpan.Zero)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
reconnectDelayStarted.TrySetResult(true);
|
||||
cancellationToken.Register(() => reconnectDelayCanceled.TrySetResult(true));
|
||||
return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
|
||||
var transport = new RuntimeDisconnectFakeTransport(DisconnectBehavior.ReturnEof)
|
||||
.WithFrame(BuildHandshakeAckFrame())
|
||||
.WithFrame(BuildAdviseAckFrame(1));
|
||||
var client = new SuiteLinkClient(
|
||||
transport,
|
||||
ownsTransport: false,
|
||||
delayAsync: DelayAsync,
|
||||
reconnectAttemptAsync: static _ => ValueTask.FromResult(false));
|
||||
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
_ = await reconnectDelayStarted.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
await client.DisconnectAsync().WaitAsync(TimeSpan.FromSeconds(2));
|
||||
_ = await reconnectDelayCanceled.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(SuiteLinkSessionState.Disconnected, client.DebugState);
|
||||
Assert.False(client.IsConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconnect_ReplaysDurableSubscriptions_AndResumesUpdateDispatch()
|
||||
{
|
||||
var updateReceived = new TaskCompletionSource<SuiteLinkTagUpdate>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var transport = new ReplayableReconnectFakeTransport(
|
||||
new ConnectionPlan(
|
||||
EmptyReceiveBehavior.ReturnEof,
|
||||
BuildHandshakeAckFrame(),
|
||||
BuildAdviseAckFrame(1)),
|
||||
new ConnectionPlan(
|
||||
EmptyReceiveBehavior.Block,
|
||||
BuildHandshakeAckFrame(),
|
||||
BuildAdviseAckFrame(1),
|
||||
BuildBooleanUpdateFrame(1, true)));
|
||||
var client = new SuiteLinkClient(
|
||||
transport,
|
||||
ownsTransport: false,
|
||||
delayAsync: static (_, _) => Task.CompletedTask);
|
||||
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", update => updateReceived.TrySetResult(update));
|
||||
|
||||
var update = await updateReceived.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(2, transport.ConnectCallCount);
|
||||
Assert.Equal(2, CountSentMessageType(transport.SentBuffers, SuiteLinkSubscriptionCodec.AdviseMessageType));
|
||||
Assert.Equal(SuiteLinkSessionState.Subscribed, client.DebugState);
|
||||
Assert.True(update.Value.TryGetBoolean(out var value));
|
||||
Assert.True(value);
|
||||
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconnect_RestoresLiveTagMappings_AndAllowsWriteAfterReplay()
|
||||
{
|
||||
var transport = new ReplayableReconnectFakeTransport(
|
||||
new ConnectionPlan(
|
||||
EmptyReceiveBehavior.ReturnEof,
|
||||
BuildHandshakeAckFrame(),
|
||||
BuildAdviseAckFrame(1)),
|
||||
new ConnectionPlan(
|
||||
EmptyReceiveBehavior.Block,
|
||||
BuildHandshakeAckFrame(),
|
||||
BuildAdviseAckFrame(1)));
|
||||
var client = new SuiteLinkClient(
|
||||
transport,
|
||||
ownsTransport: false,
|
||||
delayAsync: static (_, _) => Task.CompletedTask);
|
||||
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
await AssertStateEventuallyAsync(client, SuiteLinkSessionState.Subscribed);
|
||||
|
||||
transport.ClearSentBuffers();
|
||||
await client.WriteAsync("Pump001.Run", SuiteLinkValue.FromBoolean(false));
|
||||
|
||||
Assert.Contains(
|
||||
transport.SentBuffers,
|
||||
frameBytes => frameBytes.AsSpan().SequenceEqual(
|
||||
SuiteLinkWriteCodec.Encode(1, SuiteLinkValue.FromBoolean(false))));
|
||||
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconnect_WithRefreshLatestValue_DispatchesCatchUpReplay()
|
||||
{
|
||||
var catchUpReceived = new TaskCompletionSource<SuiteLinkTagUpdate>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var transport = new ReplayableReconnectFakeTransport(
|
||||
new ConnectionPlan(
|
||||
EmptyReceiveBehavior.ReturnEof,
|
||||
BuildHandshakeAckFrame(),
|
||||
BuildAdviseAckFrame(1)),
|
||||
new ConnectionPlan(
|
||||
EmptyReceiveBehavior.Block,
|
||||
BuildHandshakeAckFrame(),
|
||||
BuildAdviseAckFrame(1),
|
||||
BuildAdviseAckFrame(2),
|
||||
BuildBooleanUpdateFrame(2, true)));
|
||||
var client = new SuiteLinkClient(
|
||||
transport,
|
||||
ownsTransport: false,
|
||||
delayAsync: static (_, _) => Task.CompletedTask);
|
||||
|
||||
await client.ConnectAsync(CreateOptions(runtime: new SuiteLinkRuntimeOptions(
|
||||
retryPolicy: SuiteLinkRetryPolicy.Default,
|
||||
catchUpPolicy: SuiteLinkCatchUpPolicy.RefreshLatestValue,
|
||||
catchUpTimeout: TimeSpan.FromSeconds(2))));
|
||||
_ = await client.SubscribeAsync("Pump001.Run", update =>
|
||||
{
|
||||
if (update.Source == SuiteLinkUpdateSource.CatchUpReplay)
|
||||
{
|
||||
catchUpReceived.TrySetResult(update);
|
||||
}
|
||||
});
|
||||
|
||||
var catchUp = await catchUpReceived.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(SuiteLinkUpdateSource.CatchUpReplay, catchUp.Source);
|
||||
Assert.Equal(1u, catchUp.TagId);
|
||||
Assert.True(catchUp.Value.TryGetBoolean(out var value));
|
||||
Assert.True(value);
|
||||
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconnect_CatchUpTimeout_DoesNotFailRecoveredSubscriptions()
|
||||
{
|
||||
var transport = new ReplayableReconnectFakeTransport(
|
||||
new ConnectionPlan(
|
||||
EmptyReceiveBehavior.ReturnEof,
|
||||
BuildHandshakeAckFrame(),
|
||||
BuildAdviseAckFrame(1)),
|
||||
new ConnectionPlan(
|
||||
EmptyReceiveBehavior.Block,
|
||||
BuildHandshakeAckFrame(),
|
||||
BuildAdviseAckFrame(1),
|
||||
BuildAdviseAckFrame(2)));
|
||||
var client = new SuiteLinkClient(
|
||||
transport,
|
||||
ownsTransport: false,
|
||||
delayAsync: static (_, _) => Task.CompletedTask);
|
||||
|
||||
await client.ConnectAsync(CreateOptions(runtime: new SuiteLinkRuntimeOptions(
|
||||
retryPolicy: SuiteLinkRetryPolicy.Default,
|
||||
catchUpPolicy: SuiteLinkCatchUpPolicy.RefreshLatestValue,
|
||||
catchUpTimeout: TimeSpan.FromMilliseconds(100))));
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
|
||||
await AssertStateEventuallyAsync(client, SuiteLinkSessionState.Subscribed, TimeSpan.FromSeconds(2));
|
||||
Assert.True(client.IsConnected);
|
||||
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_DuringReconnect_ThrowsPredictableInvalidOperationException()
|
||||
{
|
||||
var reconnectAttemptStarted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var transport = new RuntimeDisconnectFakeTransport(DisconnectBehavior.ReturnEof)
|
||||
.WithFrame(BuildHandshakeAckFrame())
|
||||
.WithFrame(BuildAdviseAckFrame(1));
|
||||
var client = new SuiteLinkClient(
|
||||
transport,
|
||||
ownsTransport: false,
|
||||
delayAsync: static (_, _) => Task.CompletedTask,
|
||||
reconnectAttemptAsync: async cancellationToken =>
|
||||
{
|
||||
reconnectAttemptStarted.TrySetResult(true);
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
});
|
||||
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
await AssertStateEventuallyAsync(client, SuiteLinkSessionState.Reconnecting);
|
||||
_ = await reconnectAttemptStarted.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => client.WriteAsync("Pump001.Run", SuiteLinkValue.FromBoolean(false)));
|
||||
|
||||
Assert.Contains("reconnecting", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task CloseOperations_DuringReconnectAttempt_CancelRecoveryAndEndDisconnected(bool useDisposeAsync)
|
||||
{
|
||||
var reconnectAttemptStarted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var reconnectAttemptCanceled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var transport = new RuntimeDisconnectFakeTransport(DisconnectBehavior.ReturnEof)
|
||||
.WithFrame(BuildHandshakeAckFrame())
|
||||
.WithFrame(BuildAdviseAckFrame(1));
|
||||
var client = new SuiteLinkClient(
|
||||
transport,
|
||||
ownsTransport: false,
|
||||
delayAsync: static (_, _) => Task.CompletedTask,
|
||||
reconnectAttemptAsync: async cancellationToken =>
|
||||
{
|
||||
reconnectAttemptStarted.TrySetResult(true);
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
reconnectAttemptCanceled.TrySetResult(true);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
_ = await reconnectAttemptStarted.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
if (useDisposeAsync)
|
||||
{
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await client.DisconnectAsync();
|
||||
}
|
||||
|
||||
_ = await reconnectAttemptCanceled.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(SuiteLinkSessionState.Disconnected, client.DebugState);
|
||||
Assert.False(client.IsConnected);
|
||||
}
|
||||
|
||||
private static async Task AssertStateEventuallyAsync(
|
||||
SuiteLinkClient client,
|
||||
SuiteLinkSessionState expectedState,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(2));
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (client.DebugState == expectedState)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
Assert.Equal(expectedState, client.DebugState);
|
||||
}
|
||||
|
||||
private static SuiteLinkConnectionOptions CreateOptions(SuiteLinkRuntimeOptions? runtime = null)
|
||||
{
|
||||
return new SuiteLinkConnectionOptions(
|
||||
host: "127.0.0.1",
|
||||
application: "App",
|
||||
topic: "Topic",
|
||||
clientName: "Client",
|
||||
clientNode: "Node",
|
||||
userName: "User",
|
||||
serverNode: "Server",
|
||||
timezone: "UTC",
|
||||
port: 5413,
|
||||
runtime: runtime);
|
||||
}
|
||||
|
||||
private static byte[] BuildHandshakeAckFrame()
|
||||
{
|
||||
return [0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5];
|
||||
}
|
||||
|
||||
private static byte[] BuildAdviseAckFrame(params uint[] tagIds)
|
||||
{
|
||||
var payload = new byte[Math.Max(1, tagIds.Length) * 5];
|
||||
var ids = tagIds.Length == 0 ? [0u] : tagIds;
|
||||
var offset = 0;
|
||||
foreach (var tagId in ids)
|
||||
{
|
||||
SuiteLinkEncoding.WriteUInt32LittleEndian(payload.AsSpan(offset, 4), tagId);
|
||||
payload[offset + 4] = 0x00;
|
||||
offset += 5;
|
||||
}
|
||||
|
||||
return SuiteLinkFrameWriter.WriteFrame(SuiteLinkSubscriptionCodec.AdviseAckMessageType, payload);
|
||||
}
|
||||
|
||||
private static byte[] BuildBooleanUpdateFrame(uint tagId, bool value)
|
||||
{
|
||||
var payload = new byte[10];
|
||||
SuiteLinkEncoding.WriteUInt32LittleEndian(payload.AsSpan(0, 4), tagId);
|
||||
SuiteLinkEncoding.WriteUInt16LittleEndian(payload.AsSpan(4, 2), 1);
|
||||
SuiteLinkEncoding.WriteUInt16LittleEndian(payload.AsSpan(6, 2), 0x00C0);
|
||||
payload[8] = (byte)SuiteLinkWireValueType.Binary;
|
||||
payload[9] = value ? (byte)1 : (byte)0;
|
||||
return SuiteLinkFrameWriter.WriteFrame(SuiteLinkUpdateCodec.UpdateMessageType, payload);
|
||||
}
|
||||
|
||||
private static int CountSentMessageType(IEnumerable<byte[]> sentBuffers, ushort messageType)
|
||||
{
|
||||
return sentBuffers.Count(
|
||||
frameBytes =>
|
||||
SuiteLinkFrameReader.TryParseFrame(frameBytes, out var frame, out _) &&
|
||||
frame.MessageType == messageType);
|
||||
}
|
||||
|
||||
public enum DisconnectBehavior
|
||||
{
|
||||
ReturnEof,
|
||||
ThrowIoException,
|
||||
ThrowSocketException
|
||||
}
|
||||
|
||||
private enum EmptyReceiveBehavior
|
||||
{
|
||||
ReturnEof,
|
||||
Block
|
||||
}
|
||||
|
||||
private sealed record ConnectionPlan(
|
||||
EmptyReceiveBehavior EmptyReceiveBehavior,
|
||||
params byte[][] Frames);
|
||||
|
||||
private sealed class RuntimeDisconnectFakeTransport : ISuiteLinkTransport
|
||||
{
|
||||
private readonly Queue<byte[]> _receiveChunks = [];
|
||||
private readonly DisconnectBehavior _disconnectBehavior;
|
||||
|
||||
public RuntimeDisconnectFakeTransport(DisconnectBehavior disconnectBehavior)
|
||||
{
|
||||
_disconnectBehavior = disconnectBehavior;
|
||||
}
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
public int RuntimeReceiveCallCount { get; private set; }
|
||||
public TaskCompletionSource<bool>? RuntimeReceiveEntered { get; set; }
|
||||
public Task? AllowDisconnectSignal { get; set; }
|
||||
|
||||
public RuntimeDisconnectFakeTransport WithFrame(byte[] frameBytes)
|
||||
{
|
||||
_receiveChunks.Enqueue(frameBytes);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RuntimeDisconnectFakeTransport WithChunk(byte[] bytes)
|
||||
{
|
||||
_receiveChunks.Enqueue(bytes);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IsConnected = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask SendAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_receiveChunks.Count > 0)
|
||||
{
|
||||
var next = _receiveChunks.Dequeue();
|
||||
next.CopyTo(buffer);
|
||||
return next.Length;
|
||||
}
|
||||
|
||||
RuntimeReceiveCallCount++;
|
||||
RuntimeReceiveEntered?.TrySetResult(true);
|
||||
if (AllowDisconnectSignal is not null)
|
||||
{
|
||||
await AllowDisconnectSignal.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _disconnectBehavior switch
|
||||
{
|
||||
DisconnectBehavior.ReturnEof => 0,
|
||||
DisconnectBehavior.ThrowIoException =>
|
||||
throw new IOException("Synthetic runtime disconnect."),
|
||||
DisconnectBehavior.ThrowSocketException =>
|
||||
throw new SocketException((int)SocketError.ConnectionReset),
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsConnected = false;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ReplayableReconnectFakeTransport : ISuiteLinkTransport
|
||||
{
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly List<ConnectionPlan> _connectionPlans;
|
||||
private Queue<byte[]> _receiveChunks = [];
|
||||
private EmptyReceiveBehavior _emptyReceiveBehavior;
|
||||
private bool _disposed;
|
||||
|
||||
public ReplayableReconnectFakeTransport(params ConnectionPlan[] connectionPlans)
|
||||
{
|
||||
_connectionPlans = [.. connectionPlans];
|
||||
}
|
||||
|
||||
public int ConnectCallCount { get; private set; }
|
||||
public bool IsConnected => !_disposed;
|
||||
public List<byte[]> SentBuffers { get; } = [];
|
||||
|
||||
public void ClearSentBuffers()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
SentBuffers.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ConnectCallCount >= _connectionPlans.Count)
|
||||
{
|
||||
throw new InvalidOperationException("No reconnect plan is available for the requested attempt.");
|
||||
}
|
||||
|
||||
var plan = _connectionPlans[ConnectCallCount];
|
||||
ConnectCallCount++;
|
||||
_receiveChunks = new Queue<byte[]>(plan.Frames);
|
||||
_emptyReceiveBehavior = plan.EmptyReceiveBehavior;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask SendAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
SentBuffers.Add(buffer.ToArray());
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_receiveChunks.Count > 0)
|
||||
{
|
||||
var next = _receiveChunks.Dequeue();
|
||||
next.CopyTo(buffer);
|
||||
return next.Length;
|
||||
}
|
||||
|
||||
if (_emptyReceiveBehavior == EmptyReceiveBehavior.ReturnEof)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_disposed = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
272
tests/SuiteLink.Client.Tests/SuiteLinkClientRuntimeLoopTests.cs
Normal file
272
tests/SuiteLink.Client.Tests/SuiteLinkClientRuntimeLoopTests.cs
Normal file
@@ -0,0 +1,272 @@
|
||||
using System.Threading.Channels;
|
||||
using SuiteLink.Client.Protocol;
|
||||
using SuiteLink.Client.Transport;
|
||||
|
||||
namespace SuiteLink.Client.Tests;
|
||||
|
||||
public sealed class SuiteLinkClientRuntimeLoopTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConnectAsync_WithZeroSubscriptions_TransitionsToReadyOnceRuntimeLoopStarts()
|
||||
{
|
||||
var transport = new BlockingFakeTransport();
|
||||
transport.EnqueueReceive(BuildHandshakeAckFrame());
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
|
||||
Assert.True(client.IsConnected);
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_StartsBackgroundLoop_AndDispatchesUpdateWithoutManualPolling()
|
||||
{
|
||||
var updateReceived = new TaskCompletionSource<SuiteLinkTagUpdate>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var transport = new BlockingFakeTransport();
|
||||
transport.EnqueueReceive(BuildHandshakeAckFrame());
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
|
||||
transport.EnqueueReceive(BuildAdviseAckFrame(1));
|
||||
transport.EnqueueReceive(BuildBooleanUpdateFrame(1, true));
|
||||
|
||||
_ = await client.SubscribeAsync(
|
||||
"Pump001.Run",
|
||||
update => updateReceived.TrySetResult(update));
|
||||
|
||||
var update = await updateReceived.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.True(update.Value.TryGetBoolean(out var value) && value);
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuntimeLoop_CallbackCanReenterClientWriteWithoutDeadlock()
|
||||
{
|
||||
var callbackCompleted = new TaskCompletionSource<bool>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var transport = new BlockingFakeTransport();
|
||||
transport.EnqueueReceive(BuildHandshakeAckFrame());
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
|
||||
transport.EnqueueReceive(BuildAdviseAckFrame(1));
|
||||
_ = await client.SubscribeAsync(
|
||||
"Pump001.Run",
|
||||
_ =>
|
||||
{
|
||||
client.WriteAsync("Pump001.Run", SuiteLinkValue.FromBoolean(false))
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
callbackCompleted.TrySetResult(true);
|
||||
});
|
||||
|
||||
transport.EnqueueReceive(BuildBooleanUpdateFrame(1, true));
|
||||
|
||||
_ = await callbackCompleted.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var expectedPoke = SuiteLinkWriteCodec.Encode(1, SuiteLinkValue.FromBoolean(false));
|
||||
Assert.Contains(
|
||||
transport.SentBuffers,
|
||||
frameBytes => frameBytes.AsSpan().SequenceEqual(expectedPoke));
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_AwaitsRuntimeLoopStop_BeforeDisposingOwnedTransport()
|
||||
{
|
||||
var transport = new OrderedShutdownFakeTransport();
|
||||
transport.EnqueueReceive(BuildHandshakeAckFrame());
|
||||
transport.EnqueueReceive(BuildAdviseAckFrame(1));
|
||||
|
||||
var client = new SuiteLinkClient(transport, ownsTransport: true);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
_ = await transport.RuntimeReceiveEntered.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
var disposeTask = client.DisposeAsync().AsTask();
|
||||
|
||||
// The runtime loop is still blocked in receive and has not been allowed to return.
|
||||
await Task.Delay(100);
|
||||
Assert.False(disposeTask.IsCompleted);
|
||||
Assert.Equal(0, transport.DisposeCallCount);
|
||||
|
||||
transport.AllowRuntimeReceiveReturn.TrySetResult(true);
|
||||
await disposeTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(1, transport.DisposeCallCount);
|
||||
Assert.True(transport.DisposeObservedRuntimeReceiveReturned);
|
||||
}
|
||||
|
||||
private static SuiteLinkConnectionOptions CreateOptions()
|
||||
{
|
||||
return new SuiteLinkConnectionOptions(
|
||||
host: "127.0.0.1",
|
||||
application: "App",
|
||||
topic: "Topic",
|
||||
clientName: "Client",
|
||||
clientNode: "Node",
|
||||
userName: "User",
|
||||
serverNode: "Server",
|
||||
timezone: "UTC",
|
||||
port: 5413);
|
||||
}
|
||||
|
||||
private static byte[] BuildHandshakeAckFrame()
|
||||
{
|
||||
return [0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5];
|
||||
}
|
||||
|
||||
private static byte[] BuildAdviseAckFrame(params uint[] tagIds)
|
||||
{
|
||||
var payload = new byte[Math.Max(1, tagIds.Length) * 5];
|
||||
var ids = tagIds.Length == 0 ? [0u] : tagIds;
|
||||
var offset = 0;
|
||||
foreach (var tagId in ids)
|
||||
{
|
||||
SuiteLinkEncoding.WriteUInt32LittleEndian(payload.AsSpan(offset, 4), tagId);
|
||||
payload[offset + 4] = 0x00;
|
||||
offset += 5;
|
||||
}
|
||||
|
||||
return SuiteLinkFrameWriter.WriteFrame(SuiteLinkSubscriptionCodec.AdviseAckMessageType, payload);
|
||||
}
|
||||
|
||||
private static byte[] BuildBooleanUpdateFrame(uint tagId, bool value)
|
||||
{
|
||||
var payload = new byte[10];
|
||||
SuiteLinkEncoding.WriteUInt32LittleEndian(payload.AsSpan(0, 4), tagId);
|
||||
SuiteLinkEncoding.WriteUInt16LittleEndian(payload.AsSpan(4, 2), 1);
|
||||
SuiteLinkEncoding.WriteUInt16LittleEndian(payload.AsSpan(6, 2), 0x00C0);
|
||||
payload[8] = (byte)SuiteLinkWireValueType.Binary;
|
||||
payload[9] = value ? (byte)1 : (byte)0;
|
||||
return SuiteLinkFrameWriter.WriteFrame(SuiteLinkUpdateCodec.UpdateMessageType, payload);
|
||||
}
|
||||
|
||||
private sealed class BlockingFakeTransport : ISuiteLinkTransport
|
||||
{
|
||||
private readonly Channel<byte[]> _receiveChannel = Channel.CreateUnbounded<byte[]>();
|
||||
private readonly object _syncRoot = new();
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => !_disposed;
|
||||
public List<byte[]> SentBuffers { get; } = [];
|
||||
|
||||
public void EnqueueReceive(byte[] frameBytes)
|
||||
{
|
||||
if (!_receiveChannel.Writer.TryWrite(frameBytes))
|
||||
{
|
||||
throw new InvalidOperationException("Unable to enqueue receive frame.");
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(BlockingFakeTransport));
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask SendAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
SentBuffers.Add(buffer.ToArray());
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var next = await _receiveChannel.Reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
next.CopyTo(buffer);
|
||||
return next.Length;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_disposed = true;
|
||||
_receiveChannel.Writer.TryComplete();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OrderedShutdownFakeTransport : ISuiteLinkTransport
|
||||
{
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly Queue<byte[]> _startupFrames = [];
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => !_disposed;
|
||||
public int DisposeCallCount { get; private set; }
|
||||
public bool DisposeObservedRuntimeReceiveReturned { get; private set; }
|
||||
public TaskCompletionSource<bool> RuntimeReceiveEntered { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource<bool> AllowRuntimeReceiveReturn { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource<bool> RuntimeReceiveReturned { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public void EnqueueReceive(byte[] frameBytes)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_startupFrames.Enqueue(frameBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(OrderedShutdownFakeTransport));
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask SendAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_startupFrames.Count > 0)
|
||||
{
|
||||
var startupFrame = _startupFrames.Dequeue();
|
||||
startupFrame.CopyTo(buffer);
|
||||
return ValueTask.FromResult(startupFrame.Length);
|
||||
}
|
||||
}
|
||||
|
||||
RuntimeReceiveEntered.TrySetResult(true);
|
||||
return ReceiveRuntimeLoopBlockAsync();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_disposed = true;
|
||||
DisposeCallCount++;
|
||||
DisposeObservedRuntimeReceiveReturned = RuntimeReceiveReturned.Task.IsCompleted;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private async ValueTask<int> ReceiveRuntimeLoopBlockAsync()
|
||||
{
|
||||
await AllowRuntimeReceiveReturn.Task.ConfigureAwait(false);
|
||||
RuntimeReceiveReturned.TrySetResult(true);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using SuiteLink.Client.Protocol;
|
||||
using SuiteLink.Client.Transport;
|
||||
|
||||
namespace SuiteLink.Client.Tests;
|
||||
|
||||
public sealed class SuiteLinkClientSubscriptionRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_StoresDurableSubscriptionIntent()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
transport.EnqueueReceive(BuildHandshakeAckFrame());
|
||||
transport.EnqueueReceive(BuildAdviseAckFrame(1));
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
|
||||
Assert.True(client.DebugHasDurableSubscription("Pump001.Run"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_DuplicateItem_Throws_AndKeepsOriginalCallbackRegistration()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
transport.EnqueueReceive(BuildHandshakeAckFrame());
|
||||
transport.EnqueueReceive(BuildAdviseAckFrame(1));
|
||||
transport.EnqueueReceive(BuildBooleanUpdateFrame(1, true));
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
|
||||
var firstCallbackCount = 0;
|
||||
var secondCallbackCount = 0;
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => firstCallbackCount++);
|
||||
|
||||
var duplicateException = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => client.SubscribeAsync("Pump001.Run", _ => secondCallbackCount++));
|
||||
Assert.Contains("already subscribed", duplicateException.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
await client.ProcessIncomingAsync();
|
||||
|
||||
Assert.True(client.DebugHasDurableSubscription("Pump001.Run"));
|
||||
Assert.Equal(1, firstCallbackCount);
|
||||
Assert.Equal(0, secondCallbackCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscriptionHandleDisposeAsync_RemovesDurableSubscriptionIntent()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
transport.EnqueueReceive(BuildHandshakeAckFrame());
|
||||
transport.EnqueueReceive(BuildAdviseAckFrame(1));
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
|
||||
var handle = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
Assert.True(client.DebugHasDurableSubscription("Pump001.Run"));
|
||||
|
||||
await handle.DisposeAsync();
|
||||
|
||||
Assert.False(client.DebugHasDurableSubscription("Pump001.Run"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscriptionHandleDisposeAsync_RemovesDurableIntent_WhenUnadviseSendFails()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
transport.EnqueueReceive(BuildHandshakeAckFrame());
|
||||
transport.EnqueueReceive(BuildAdviseAckFrame(1));
|
||||
transport.SendFailureFactory = frameBytes =>
|
||||
{
|
||||
var span = frameBytes.Span;
|
||||
var isUnadviseFrame = span.Length >= 4 &&
|
||||
span[2] == 0x04 &&
|
||||
span[3] == 0x80;
|
||||
return isUnadviseFrame ? new IOException("Synthetic unadvise send failure.") : null;
|
||||
};
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
|
||||
var handle = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
Assert.True(client.DebugHasDurableSubscription("Pump001.Run"));
|
||||
|
||||
await Assert.ThrowsAsync<IOException>(() => handle.DisposeAsync().AsTask());
|
||||
Assert.False(client.DebugHasDurableSubscription("Pump001.Run"));
|
||||
}
|
||||
|
||||
private static SuiteLinkConnectionOptions CreateOptions()
|
||||
{
|
||||
return new SuiteLinkConnectionOptions(
|
||||
host: "127.0.0.1",
|
||||
application: "App",
|
||||
topic: "Topic",
|
||||
clientName: "Client",
|
||||
clientNode: "Node",
|
||||
userName: "User",
|
||||
serverNode: "Server",
|
||||
timezone: "UTC",
|
||||
port: 5413);
|
||||
}
|
||||
|
||||
private static byte[] BuildHandshakeAckFrame()
|
||||
{
|
||||
return [0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5];
|
||||
}
|
||||
|
||||
private static byte[] BuildAdviseAckFrame(uint tagId)
|
||||
{
|
||||
Span<byte> payload = stackalloc byte[5];
|
||||
SuiteLinkEncoding.WriteUInt32LittleEndian(payload[..4], tagId);
|
||||
payload[4] = 0x00;
|
||||
return SuiteLinkFrameWriter.WriteFrame(SuiteLinkSubscriptionCodec.AdviseAckMessageType, payload);
|
||||
}
|
||||
|
||||
private static byte[] BuildBooleanUpdateFrame(uint tagId, bool value)
|
||||
{
|
||||
Span<byte> payload = stackalloc byte[10];
|
||||
SuiteLinkEncoding.WriteUInt32LittleEndian(payload[..4], tagId);
|
||||
SuiteLinkEncoding.WriteUInt16LittleEndian(payload.Slice(4, 2), 1);
|
||||
SuiteLinkEncoding.WriteUInt16LittleEndian(payload.Slice(6, 2), 0x00C0);
|
||||
payload[8] = (byte)SuiteLinkWireValueType.Binary;
|
||||
payload[9] = value ? (byte)1 : (byte)0;
|
||||
return SuiteLinkFrameWriter.WriteFrame(SuiteLinkUpdateCodec.UpdateMessageType, payload);
|
||||
}
|
||||
|
||||
private sealed class FakeTransport : ISuiteLinkTransport
|
||||
{
|
||||
private readonly Queue<byte[]> _receiveChunks = [];
|
||||
public Func<ReadOnlyMemory<byte>, Exception?>? SendFailureFactory { get; set; }
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public void EnqueueReceive(byte[] bytes)
|
||||
{
|
||||
_receiveChunks.Enqueue(bytes);
|
||||
}
|
||||
|
||||
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IsConnected = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask SendAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sendFailure = SendFailureFactory?.Invoke(buffer);
|
||||
if (sendFailure is not null)
|
||||
{
|
||||
throw sendFailure;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_receiveChunks.Count == 0)
|
||||
{
|
||||
return ValueTask.FromResult(0);
|
||||
}
|
||||
|
||||
var bytes = _receiveChunks.Dequeue();
|
||||
bytes.CopyTo(buffer);
|
||||
return ValueTask.FromResult(bytes.Length);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsConnected = false;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using SuiteLink.Client.Protocol;
|
||||
using SuiteLink.Client.Internal;
|
||||
using SuiteLink.Client.Transport;
|
||||
|
||||
namespace SuiteLink.Client.Tests;
|
||||
@@ -42,6 +43,73 @@ public sealed class SuiteLinkClientWriteTests
|
||||
Assert.Equal(2, transport.SentBuffers.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_DuringReconnect_ThrowsClearException()
|
||||
{
|
||||
var transport = new RuntimeDisconnectFakeTransport()
|
||||
.WithFrame(BuildHandshakeAckFrame())
|
||||
.WithFrame(BuildAdviseAckFrame(1));
|
||||
var client = new SuiteLinkClient(
|
||||
transport,
|
||||
ownsTransport: false,
|
||||
delayAsync: static async (delay, cancellationToken) =>
|
||||
{
|
||||
await Task.Yield();
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
},
|
||||
reconnectAttemptAsync: static _ => ValueTask.FromResult(false));
|
||||
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
while (DateTime.UtcNow < deadline && client.DebugState != SuiteLinkSessionState.Reconnecting)
|
||||
{
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => client.WriteAsync("Pump001.Run", SuiteLinkValue.FromBoolean(true)));
|
||||
|
||||
Assert.Contains("reconnecting", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_DuringReconnect_ThrowsBeforeWaitingOnOperationGate()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
transport.EnqueueReceive(BuildHandshakeAckFrame());
|
||||
transport.EnqueueReceive(BuildAdviseAckFrame(1));
|
||||
|
||||
var client = new SuiteLinkClient(transport);
|
||||
await client.ConnectAsync(CreateOptions());
|
||||
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||
|
||||
var releaseGate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var acquiredGate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var holdGateTask = client.DebugHoldOperationGateAsync(releaseGate.Task, acquiredGate);
|
||||
_ = await acquiredGate.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
var sessionField = typeof(SuiteLinkClient)
|
||||
.GetField("_session", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||
var session = (SuiteLinkSession)sessionField.GetValue(client)!;
|
||||
Assert.True(session.TryTransitionState(SuiteLinkSessionState.Subscribed, SuiteLinkSessionState.Reconnecting));
|
||||
|
||||
var writeTask = client.WriteAsync("Pump001.Run", SuiteLinkValue.FromBoolean(true));
|
||||
var completed = await Task.WhenAny(writeTask, Task.Delay(200));
|
||||
|
||||
releaseGate.TrySetResult(true);
|
||||
await holdGateTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Same(writeTask, completed);
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => writeTask);
|
||||
Assert.Contains("reconnecting", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static SuiteLinkConnectionOptions CreateOptions()
|
||||
{
|
||||
return new SuiteLinkConnectionOptions(
|
||||
@@ -123,4 +191,54 @@ public sealed class SuiteLinkClientWriteTests
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RuntimeDisconnectFakeTransport : ISuiteLinkTransport
|
||||
{
|
||||
private readonly Queue<byte[]> _receiveChunks = [];
|
||||
private readonly object _syncRoot = new();
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public RuntimeDisconnectFakeTransport WithFrame(byte[] bytes)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_receiveChunks.Enqueue(bytes);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IsConnected = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask SendAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_receiveChunks.Count == 0)
|
||||
{
|
||||
return new ValueTask<int>(0);
|
||||
}
|
||||
|
||||
var next = _receiveChunks.Dequeue();
|
||||
next.CopyTo(buffer);
|
||||
return new ValueTask<int>(next.Length);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsConnected = false;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,37 @@ public sealed class SuiteLinkConnectionOptionsTests
|
||||
Assert.Equal("America/Indiana/Indianapolis", options.Timezone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultsRuntimeOptions()
|
||||
{
|
||||
var options = Create();
|
||||
|
||||
Assert.NotNull(options.Runtime);
|
||||
Assert.Equal(SuiteLinkCatchUpPolicy.None, options.Runtime.CatchUpPolicy);
|
||||
Assert.NotNull(options.Runtime.RetryPolicy);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), options.Runtime.CatchUpTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_RuntimeWithNullRetryPolicy_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => Create(runtime: new SuiteLinkRuntimeOptions(
|
||||
retryPolicy: null!,
|
||||
catchUpPolicy: SuiteLinkCatchUpPolicy.None,
|
||||
catchUpTimeout: TimeSpan.FromSeconds(2))));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void Constructor_RuntimeWithNonPositiveCatchUpTimeout_ThrowsArgumentOutOfRangeException(int seconds)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Create(runtime: new SuiteLinkRuntimeOptions(
|
||||
retryPolicy: SuiteLinkRetryPolicy.Default,
|
||||
catchUpPolicy: SuiteLinkCatchUpPolicy.None,
|
||||
catchUpTimeout: TimeSpan.FromSeconds(seconds))));
|
||||
}
|
||||
|
||||
private static SuiteLinkConnectionOptions Create(
|
||||
string host = "127.0.0.1",
|
||||
string application = "TestApp",
|
||||
@@ -107,7 +138,8 @@ public sealed class SuiteLinkConnectionOptionsTests
|
||||
string userName = "User",
|
||||
string serverNode = "Server",
|
||||
string? timezone = null,
|
||||
int port = 5413)
|
||||
int port = 5413,
|
||||
SuiteLinkRuntimeOptions? runtime = null)
|
||||
{
|
||||
return new SuiteLinkConnectionOptions(
|
||||
host,
|
||||
@@ -118,6 +150,7 @@ public sealed class SuiteLinkConnectionOptionsTests
|
||||
userName,
|
||||
serverNode,
|
||||
timezone,
|
||||
port);
|
||||
port,
|
||||
runtime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,38 @@ public sealed class SuiteLinkTcpTransportTests
|
||||
async () => await listener.AcceptTcpClientAsync(secondAcceptCts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetConnectionAsync_AfterConnect_AllowsReconnect()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
var endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
await using var transport = new SuiteLinkTcpTransport();
|
||||
|
||||
await transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port);
|
||||
using var accepted1 = await listener.AcceptTcpClientAsync();
|
||||
|
||||
await transport.ResetConnectionAsync();
|
||||
Assert.False(transport.IsConnected);
|
||||
|
||||
await transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port);
|
||||
using var accepted2 = await listener.AcceptTcpClientAsync();
|
||||
|
||||
Assert.True(transport.IsConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetConnectionAsync_LeaveOpenTrue_DoesNotDisposeInjectedStream()
|
||||
{
|
||||
var stream = new TrackingStream();
|
||||
await using var transport = new SuiteLinkTcpTransport(stream, leaveOpen: true);
|
||||
|
||||
await transport.ResetConnectionAsync();
|
||||
|
||||
Assert.False(stream.WasDisposed);
|
||||
}
|
||||
|
||||
private sealed class PartialReadStream : Stream
|
||||
{
|
||||
private readonly MemoryStream _inner;
|
||||
|
||||
Reference in New Issue
Block a user