feat: add resilient reconnect and catch-up replay

This commit is contained in:
Joseph Doherty
2026-03-17 11:04:19 -04:00
parent c278f98496
commit 2f04ec9d1d
29 changed files with 3746 additions and 95 deletions

View File

@@ -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`.

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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(

View 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;
}
}
}

View 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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;