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

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