feat: add resilient reconnect and catch-up replay
This commit is contained in:
15
README.md
15
README.md
@@ -10,6 +10,10 @@ Current implementation targets the normal SuiteLink tag protocol and supports:
|
|||||||
- subscribe and unadvise flows
|
- subscribe and unadvise flows
|
||||||
- update decoding for `bool`, `int32`, `float32`, and `string`
|
- update decoding for `bool`, `int32`, `float32`, and `string`
|
||||||
- write (`POKE`) encoding for `bool`, `int32`, `float32`, and `string`
|
- write (`POKE`) encoding for `bool`, `int32`, `float32`, and `string`
|
||||||
|
- background receive loop for subscription updates without manual polling
|
||||||
|
- automatic reconnect with durable subscription replay after runtime disconnects
|
||||||
|
- best-effort latest-value catch-up replay after reconnect when enabled
|
||||||
|
- policy-based reconnect retry timing with exponential backoff and jitter support
|
||||||
- client/session/transport layers suitable for macOS, Linux, and Windows
|
- client/session/transport layers suitable for macOS, Linux, and Windows
|
||||||
|
|
||||||
## Unsupported
|
## Unsupported
|
||||||
@@ -18,8 +22,9 @@ This repository does not currently support:
|
|||||||
|
|
||||||
- AlarmMgr / alarms and events
|
- AlarmMgr / alarms and events
|
||||||
- secure SuiteLink V3 / TLS transport
|
- secure SuiteLink V3 / TLS transport
|
||||||
- automatic reconnect
|
- write queuing while reconnecting
|
||||||
- background receive loop / production retry behavior
|
- full outage-history replay of every missed value
|
||||||
|
- validated reconnect behavior against a live AVEVA server deployment
|
||||||
- validated support for richer System Platform data types such as `double`, `int64`, or `DateTime`
|
- validated support for richer System Platform data types such as `double`, `int64`, or `DateTime`
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
@@ -56,3 +61,9 @@ See [README.md](/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.I
|
|||||||
|
|
||||||
- The repository includes fixture-backed protocol tests under [Fixtures](/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Fixtures).
|
- The repository includes fixture-backed protocol tests under [Fixtures](/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Fixtures).
|
||||||
- Protocol assumptions derived from reverse engineering are intentionally isolated in codec classes and tests so they can be refined against live captures later.
|
- Protocol assumptions derived from reverse engineering are intentionally isolated in codec classes and tests so they can be refined against live captures later.
|
||||||
|
- Normal subscription use does not require calling `ProcessIncomingAsync`; the runtime loop dispatches updates in the background.
|
||||||
|
- After a runtime disconnect, the client enters `Reconnecting`, rebuilds the transport/session startup sequence, replays durable subscriptions, and resumes dispatch without caller resubscription.
|
||||||
|
- Reconnect timing is driven by `SuiteLinkRetryPolicy`; the default policy retries immediately, then uses bounded exponential backoff with jitter.
|
||||||
|
- Catch-up replay is best-effort latest-value refresh only. It does not represent every value missed during the outage window.
|
||||||
|
- If the client is reconnecting after a runtime disconnect, `WriteAsync` fails fast until the session is ready again.
|
||||||
|
- Reconnect behavior is currently verified with fixture-backed and fake-transport tests; live AVEVA validation is still required, especially for mixed-mode deployments and server-specific timing.
|
||||||
|
|||||||
583
docs/plans/2026-03-17-catchup-retry-implementation-plan.md
Normal file
583
docs/plans/2026-03-17-catchup-retry-implementation-plan.md
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
# Catch-Up Replay And Advanced Retry Implementation Plan
|
||||||
|
|
||||||
|
> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add best-effort latest-value catch-up after reconnect and replace the fixed reconnect delay schedule with a production-grade retry policy, while also fixing the current reconnect quality issues.
|
||||||
|
|
||||||
|
**Architecture:** Extend the existing reconnect runtime with a small runtime-options layer, a retry-policy calculator, and a post-reconnect catch-up refresh phase. Keep reconnect success defined as restored live subscriptions, and treat catch-up as a best-effort follow-on phase that emits synthetic updates marked separately from live traffic.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, C#, xUnit, existing SuiteLink protocol/client/runtime/transport layers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Runtime Option Types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkRuntimeOptions.cs`
|
||||||
|
- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkRetryPolicy.cs`
|
||||||
|
- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkCatchUpPolicy.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkConnectionOptions.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkConnectionOptionsTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void ConnectionOptions_DefaultsRuntimeOptions()
|
||||||
|
{
|
||||||
|
var options = new SuiteLinkConnectionOptions(
|
||||||
|
host: "127.0.0.1",
|
||||||
|
application: "App",
|
||||||
|
topic: "Topic",
|
||||||
|
clientName: "Client",
|
||||||
|
clientNode: "Node",
|
||||||
|
userName: "User",
|
||||||
|
serverNode: "Server");
|
||||||
|
|
||||||
|
Assert.NotNull(options.Runtime);
|
||||||
|
Assert.Equal(SuiteLinkCatchUpPolicy.None, options.Runtime.CatchUpPolicy);
|
||||||
|
Assert.NotNull(options.Runtime.RetryPolicy);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter ConnectionOptions_DefaultsRuntimeOptions -v minimal`
|
||||||
|
Expected: FAIL because runtime options do not exist yet
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum SuiteLinkCatchUpPolicy
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
RefreshLatestValue = 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record class SuiteLinkRetryPolicy(
|
||||||
|
TimeSpan InitialDelay,
|
||||||
|
double Multiplier,
|
||||||
|
TimeSpan MaxDelay,
|
||||||
|
int? MaxAttempts = null,
|
||||||
|
bool UseJitter = true)
|
||||||
|
{
|
||||||
|
public static SuiteLinkRetryPolicy Default { get; } =
|
||||||
|
new(TimeSpan.FromSeconds(1), 2.0, TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record class SuiteLinkRuntimeOptions(
|
||||||
|
SuiteLinkRetryPolicy RetryPolicy,
|
||||||
|
SuiteLinkCatchUpPolicy CatchUpPolicy,
|
||||||
|
TimeSpan CatchUpTimeout)
|
||||||
|
{
|
||||||
|
public static SuiteLinkRuntimeOptions Default { get; } =
|
||||||
|
new(SuiteLinkRetryPolicy.Default, SuiteLinkCatchUpPolicy.None, TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `SuiteLinkConnectionOptions` to expose:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public SuiteLinkRuntimeOptions Runtime { get; }
|
||||||
|
```
|
||||||
|
|
||||||
|
and default it to `SuiteLinkRuntimeOptions.Default`.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkConnectionOptionsTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkRuntimeOptions.cs /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkRetryPolicy.cs /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkCatchUpPolicy.cs /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkConnectionOptions.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkConnectionOptionsTests.cs
|
||||||
|
git commit -m "feat: add runtime reconnect option types"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Add Retry Policy Delay Calculator
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkRetryDelayCalculator.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Internal/SuiteLinkRetryDelayCalculatorTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkRetryDelayCalculatorTests -v minimal`
|
||||||
|
Expected: FAIL because calculator does not exist yet
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal static class SuiteLinkRetryDelayCalculator
|
||||||
|
{
|
||||||
|
public static TimeSpan GetDelay(SuiteLinkRetryPolicy policy, int attempt)
|
||||||
|
{
|
||||||
|
if (attempt == 0)
|
||||||
|
{
|
||||||
|
return TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawSeconds = policy.InitialDelay.TotalSeconds * Math.Pow(policy.Multiplier, attempt - 1);
|
||||||
|
var bounded = TimeSpan.FromSeconds(Math.Min(rawSeconds, policy.MaxDelay.TotalSeconds));
|
||||||
|
return bounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not add jitter yet beyond the policy flag unless tests require it.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkRetryDelayCalculatorTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkRetryDelayCalculator.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Internal/SuiteLinkRetryDelayCalculatorTests.cs
|
||||||
|
git commit -m "feat: add reconnect retry delay calculator"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Wire Retry Policy Into Reconnect Runtime
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconnect_UsesConfiguredRetryPolicy()
|
||||||
|
{
|
||||||
|
var observed = new List<TimeSpan>();
|
||||||
|
var options = CreateOptions() with
|
||||||
|
{
|
||||||
|
Runtime = new SuiteLinkRuntimeOptions(
|
||||||
|
new SuiteLinkRetryPolicy(TimeSpan.FromSeconds(3), 3.0, TimeSpan.FromSeconds(20), UseJitter: false),
|
||||||
|
SuiteLinkCatchUpPolicy.None,
|
||||||
|
TimeSpan.FromSeconds(2))
|
||||||
|
};
|
||||||
|
|
||||||
|
var client = CreateReconnectClient(delayAsync: (delay, _) =>
|
||||||
|
{
|
||||||
|
observed.Add(delay);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.ConnectAsync(options);
|
||||||
|
await EventuallyReconnectAsync(client);
|
||||||
|
|
||||||
|
Assert.Contains(TimeSpan.FromSeconds(3), observed);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter Reconnect_UsesConfiguredRetryPolicy -v minimal`
|
||||||
|
Expected: FAIL because reconnect still uses a fixed schedule
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
In `SuiteLinkClient`:
|
||||||
|
|
||||||
|
- remove direct use of `ReconnectDelaySchedule`
|
||||||
|
- read retry policy from `_connectionOptions!.Runtime.RetryPolicy`
|
||||||
|
- use `SuiteLinkRetryDelayCalculator.GetDelay(policy, attempt)`
|
||||||
|
|
||||||
|
Keep the current injected `_delayAsync` test seam.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientReconnectTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs
|
||||||
|
git commit -m "feat: apply retry policy to reconnect runtime"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Fix Fast-Fail Writes During Reconnect
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientWriteTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_DuringReconnect_ThrowsBeforeWaitingOnOperationGate()
|
||||||
|
{
|
||||||
|
var client = CreateClientWithBlockedOperationGateAndReconnectState();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => client.WriteAsync("Pump001.Run", SuiteLinkValue.FromBoolean(true)));
|
||||||
|
|
||||||
|
Assert.Contains("reconnecting", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter WriteAsync_DuringReconnect_ThrowsBeforeWaitingOnOperationGate -v minimal`
|
||||||
|
Expected: FAIL because `WriteAsync` currently waits on `_operationGate` first
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Move the reconnect state check ahead of:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await _operationGate.WaitAsync(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
while keeping disposed-state checks intact.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientWriteTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientWriteTests.cs
|
||||||
|
git commit -m "fix: fail writes before reconnect gate contention"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Fix Transport Reset Ownership Semantics
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Transport/SuiteLinkTcpTransport.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Transport/ISuiteLinkReconnectableTransport.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Transport/SuiteLinkTcpTransportTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter ResetConnectionAsync_LeaveOpenTrue_DoesNotDisposeInjectedStream -v minimal`
|
||||||
|
Expected: FAIL because reset currently disposes caller-owned resources
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Update `ResetConnectionAsync` to respect the same ownership rule as `DisposeAsync`:
|
||||||
|
|
||||||
|
- if `leaveOpen` is `true`, detach without disposing injected resources
|
||||||
|
- if `leaveOpen` is `false`, dispose detached resources
|
||||||
|
|
||||||
|
Do not broaden interface scope unnecessarily.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkTcpTransportTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Transport/SuiteLinkTcpTransport.cs /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Transport/ISuiteLinkReconnectableTransport.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Transport/SuiteLinkTcpTransportTests.cs
|
||||||
|
git commit -m "fix: preserve transport ownership during reconnect reset"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Add Update Source Metadata
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkUpdateSource.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkTagUpdate.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkValueTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void TagUpdate_DefaultSource_IsLive()
|
||||||
|
{
|
||||||
|
var update = new SuiteLinkTagUpdate(
|
||||||
|
"Pump001.Run",
|
||||||
|
1,
|
||||||
|
SuiteLinkValue.FromBoolean(true),
|
||||||
|
0x00C0,
|
||||||
|
1,
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
Assert.Equal(SuiteLinkUpdateSource.Live, update.Source);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter TagUpdate_DefaultSource_IsLive -v minimal`
|
||||||
|
Expected: FAIL because source metadata does not exist
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum SuiteLinkUpdateSource
|
||||||
|
{
|
||||||
|
Live = 0,
|
||||||
|
CatchUpReplay = 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `Source` to `SuiteLinkTagUpdate` with default:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
SuiteLinkUpdateSource.Live
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkTagUpdate -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkUpdateSource.cs /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkTagUpdate.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkValueTests.cs
|
||||||
|
git commit -m "feat: add update source metadata"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: Add Best-Effort Catch-Up Refresh Execution
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SubscriptionRegistrationEntry.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconnect_WithRefreshLatestValue_CanDispatchCatchUpReplay()
|
||||||
|
{
|
||||||
|
SuiteLinkTagUpdate? catchUp = null;
|
||||||
|
var client = CreateReconnectReplayClient(
|
||||||
|
catchUpPolicy: SuiteLinkCatchUpPolicy.RefreshLatestValue,
|
||||||
|
onUpdate: update =>
|
||||||
|
{
|
||||||
|
if (update.Source == SuiteLinkUpdateSource.CatchUpReplay)
|
||||||
|
{
|
||||||
|
catchUp = update;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.ConnectAsync(CreateOptionsWithCatchUp());
|
||||||
|
|
||||||
|
Assert.NotNull(catchUp);
|
||||||
|
Assert.Equal(SuiteLinkUpdateSource.CatchUpReplay, catchUp.Source);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter Reconnect_WithRefreshLatestValue_CanDispatchCatchUpReplay -v minimal`
|
||||||
|
Expected: FAIL because reconnect only resumes live dispatch today
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
After successful reconnect and durable subscription replay:
|
||||||
|
|
||||||
|
- if `Runtime.CatchUpPolicy == SuiteLinkCatchUpPolicy.RefreshLatestValue`
|
||||||
|
- run a sequential refresh pass over durable subscriptions
|
||||||
|
- obtain one fresh value per item using existing temporary-read machinery or a dedicated internal refresh path
|
||||||
|
- dispatch synthetic updates with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Source: SuiteLinkUpdateSource.CatchUpReplay
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not fail reconnect if one item refresh fails or times out.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientReconnectTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SubscriptionRegistrationEntry.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs
|
||||||
|
git commit -m "feat: add reconnect catch-up refresh replay"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8: Make Catch-Up Partial Failure Non-Fatal
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconnect_CatchUpTimeout_DoesNotFailRecoveredSubscriptions()
|
||||||
|
{
|
||||||
|
var client = CreateReconnectReplayClientWithTimedOutRefresh();
|
||||||
|
|
||||||
|
await client.ConnectAsync(CreateOptionsWithCatchUp());
|
||||||
|
|
||||||
|
await Eventually.AssertAsync(() => Assert.True(client.IsConnected));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter Reconnect_CatchUpTimeout_DoesNotFailRecoveredSubscriptions -v minimal`
|
||||||
|
Expected: FAIL if catch-up failure tears down reconnect
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Wrap each refresh item independently:
|
||||||
|
|
||||||
|
- timeout per item from `Runtime.CatchUpTimeout`
|
||||||
|
- swallow per-item failure after optionally recording internal debug signal
|
||||||
|
- continue to remaining items
|
||||||
|
|
||||||
|
Do not change the recovered `Ready`/`Subscribed` state.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientReconnectTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs
|
||||||
|
git commit -m "feat: tolerate partial catch-up refresh failures"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 9: Add Jitter Coverage Without Flaky Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkRetryDelayCalculator.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Internal/SuiteLinkRetryDelayCalculatorTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkRetryDelayCalculatorTests -v minimal`
|
||||||
|
Expected: FAIL because jitter injection does not exist yet
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add an injected random source overload:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static TimeSpan GetDelay(SuiteLinkRetryPolicy policy, int attempt, Func<double>? nextDouble = null)
|
||||||
|
```
|
||||||
|
|
||||||
|
When jitter is enabled:
|
||||||
|
|
||||||
|
- compute bounded base delay
|
||||||
|
- apply deterministic injected random value in tests
|
||||||
|
- keep final value within `[0, MaxDelay]`
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkRetryDelayCalculatorTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkRetryDelayCalculator.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/Internal/SuiteLinkRetryDelayCalculatorTests.cs
|
||||||
|
git commit -m "feat: add deterministic jitter coverage for retry policy"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 10: Update Documentation And Final Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/README.md`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests/README.md`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-17-catchup-retry-design.md`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-17-catchup-retry-implementation-plan.md`
|
||||||
|
|
||||||
|
**Step 1: Write the documentation diff**
|
||||||
|
|
||||||
|
Document:
|
||||||
|
|
||||||
|
- catch-up mode is latest-value refresh only
|
||||||
|
- retry policy is configurable and jittered by default
|
||||||
|
- reconnect success is separate from best-effort catch-up completion
|
||||||
|
- writes still fail during reconnect
|
||||||
|
|
||||||
|
**Step 2: Run targeted verification**
|
||||||
|
|
||||||
|
Run: `rg -n "catch-up|retry|reconnect|jitter|refresh latest|reconnecting" /Users/dohertj2/Desktop/suitelinkclient/README.md /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests/README.md`
|
||||||
|
Expected: PASS with updated wording
|
||||||
|
|
||||||
|
**Step 3: Run full verification**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/README.md /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests/README.md /Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-17-catchup-retry-design.md /Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-17-catchup-retry-implementation-plan.md
|
||||||
|
git commit -m "docs: describe catch-up replay and retry policy"
|
||||||
|
```
|
||||||
225
docs/plans/2026-03-17-runtime-reconnect-design.md
Normal file
225
docs/plans/2026-03-17-runtime-reconnect-design.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# SuiteLink Runtime Reconnect Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a background receive loop and automatic reconnect/recovery to the existing SuiteLink client so subscriptions are restored automatically and update callbacks resume without caller intervention.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This design adds:
|
||||||
|
|
||||||
|
- a background receive loop owned by `SuiteLinkClient`
|
||||||
|
- automatic reconnect with bounded retry backoff
|
||||||
|
- automatic subscription replay after reconnect
|
||||||
|
- resumed update dispatch after replay
|
||||||
|
|
||||||
|
This design does not add:
|
||||||
|
|
||||||
|
- write queuing during reconnect
|
||||||
|
- catch-up replay of missed values
|
||||||
|
- secure SuiteLink V3/TLS support
|
||||||
|
- AlarmMgr support
|
||||||
|
|
||||||
|
## Runtime Model
|
||||||
|
|
||||||
|
The current client uses explicit on-demand inbound processing. The new model shifts normal operation to a managed runtime loop.
|
||||||
|
|
||||||
|
There are two categories of state:
|
||||||
|
|
||||||
|
- durable desired state
|
||||||
|
- configured connection options
|
||||||
|
- caller subscription intent
|
||||||
|
- callbacks associated with subscribed items
|
||||||
|
- ephemeral connection state
|
||||||
|
- current transport connection
|
||||||
|
- current session state
|
||||||
|
- current `itemName <-> tagId` mappings
|
||||||
|
|
||||||
|
Durable state survives reconnects. Ephemeral state is rebuilt on reconnect.
|
||||||
|
|
||||||
|
## Recommended Approach
|
||||||
|
|
||||||
|
Implement a supervised background receive loop inside `SuiteLinkClient`.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
1. `ConnectAsync` establishes the initial transport/session and starts the receive loop.
|
||||||
|
2. The receive loop reads frames continuously.
|
||||||
|
3. Update frames are decoded and dispatched to user callbacks.
|
||||||
|
4. EOF, transport exceptions, malformed frames, or replay failures trigger recovery.
|
||||||
|
5. Recovery reconnects with bounded retry delays.
|
||||||
|
6. After reconnect succeeds, the client replays all current subscriptions and resumes dispatching.
|
||||||
|
|
||||||
|
This keeps the public API simple and avoids forcing callers to manually poll `ProcessIncomingAsync`.
|
||||||
|
|
||||||
|
## State Model
|
||||||
|
|
||||||
|
Expand session/client lifecycle to distinguish pending vs ready vs reconnecting:
|
||||||
|
|
||||||
|
- `Disconnected`
|
||||||
|
- `Connecting`
|
||||||
|
- `ConnectSent`
|
||||||
|
- `Ready`
|
||||||
|
- `Reconnecting`
|
||||||
|
- `Faulted`
|
||||||
|
- `Disposed`
|
||||||
|
|
||||||
|
Definitions:
|
||||||
|
|
||||||
|
- `Connecting`: transport connect + handshake in progress
|
||||||
|
- `ConnectSent`: startup connect has been sent but the runtime is not yet considered ready
|
||||||
|
- `Ready`: background receive loop active and subscriptions can be served normally
|
||||||
|
- `Reconnecting`: recovery loop active after a connection failure
|
||||||
|
|
||||||
|
`IsConnected` should reflect `Ready` only.
|
||||||
|
|
||||||
|
## Recovery Policy
|
||||||
|
|
||||||
|
Failure triggers:
|
||||||
|
|
||||||
|
- transport read returns `0`
|
||||||
|
- transport exception while sending or receiving
|
||||||
|
- malformed or unexpected frame during active runtime
|
||||||
|
- reconnect replay failure
|
||||||
|
|
||||||
|
Recovery behavior:
|
||||||
|
|
||||||
|
- stop the current receive loop
|
||||||
|
- mark the ephemeral session as disconnected/faulted
|
||||||
|
- start reconnect attempts until success or explicit shutdown
|
||||||
|
|
||||||
|
Retry schedule:
|
||||||
|
|
||||||
|
- first retry immediately
|
||||||
|
- then bounded retry delays such as:
|
||||||
|
- 1 second
|
||||||
|
- 2 seconds
|
||||||
|
- 5 seconds
|
||||||
|
- 10 seconds
|
||||||
|
- cap the delay instead of growing without bound
|
||||||
|
|
||||||
|
Writes during `Reconnecting` are rejected with a clear exception.
|
||||||
|
|
||||||
|
## Subscription Replay
|
||||||
|
|
||||||
|
The client should maintain a durable subscription registry keyed by `itemName`.
|
||||||
|
|
||||||
|
Each entry stores:
|
||||||
|
|
||||||
|
- `itemName`
|
||||||
|
- callback
|
||||||
|
- requested tag id
|
||||||
|
|
||||||
|
During reconnect:
|
||||||
|
|
||||||
|
1. reconnect transport
|
||||||
|
2. send handshake
|
||||||
|
3. send connect
|
||||||
|
4. replay every subscribed item via `ADVISE`
|
||||||
|
5. rebuild live session mappings from fresh ACKs
|
||||||
|
6. transition to `Ready`
|
||||||
|
|
||||||
|
Subscription replay is serialized and must not run concurrently with normal writes or new replay attempts.
|
||||||
|
|
||||||
|
## Callback Rules
|
||||||
|
|
||||||
|
Callbacks must never run under client locks or gates.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- decode frames under internal synchronization
|
||||||
|
- dispatch callbacks only after releasing gates
|
||||||
|
- callback exceptions remain contained and do not crash the receive loop
|
||||||
|
|
||||||
|
## Public API Effects
|
||||||
|
|
||||||
|
Expected public behavior:
|
||||||
|
|
||||||
|
- `ConnectAsync`
|
||||||
|
- establishes initial runtime and starts background receive
|
||||||
|
- `SubscribeAsync`
|
||||||
|
- records durable intent
|
||||||
|
- advises immediately when ready
|
||||||
|
- keeps durable subscription for replay after reconnect
|
||||||
|
- `ReadAsync`
|
||||||
|
- can remain implemented as a temporary subscription
|
||||||
|
- should still use the background runtime instead of manual caller polling
|
||||||
|
- `WriteAsync`
|
||||||
|
- allowed only in `Ready`
|
||||||
|
- fails during `Reconnecting`
|
||||||
|
- `DisconnectAsync`
|
||||||
|
- stops receive and reconnect tasks
|
||||||
|
- tears down transport
|
||||||
|
|
||||||
|
`ProcessIncomingAsync` should stop being the primary runtime API. It can be retained only as an internal/test helper if still useful.
|
||||||
|
|
||||||
|
## Internal Changes
|
||||||
|
|
||||||
|
### `SuiteLinkClient`
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
- receive loop task
|
||||||
|
- reconnect supervisor task or integrated recovery loop
|
||||||
|
- cancellation tokens for runtime shutdown
|
||||||
|
- durable subscription registry
|
||||||
|
- reconnect backoff helper
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- own runtime lifecycle
|
||||||
|
- coordinate reconnect attempts
|
||||||
|
- replay subscriptions safely
|
||||||
|
- ensure only one receive loop and one reconnect flow are active
|
||||||
|
|
||||||
|
### `SuiteLinkSession`
|
||||||
|
|
||||||
|
Continue to manage:
|
||||||
|
|
||||||
|
- live connection/session state
|
||||||
|
- current `itemName <-> tagId` mappings
|
||||||
|
- live dispatch helpers
|
||||||
|
|
||||||
|
Do not make it responsible for durable reconnect intent.
|
||||||
|
|
||||||
|
### `SubscriptionHandle`
|
||||||
|
|
||||||
|
Should continue to remove durable subscription intent and trigger `UNADVISE` when possible.
|
||||||
|
|
||||||
|
If called during reconnect/disconnect, removal of durable intent still succeeds even if wire unadvise cannot be sent.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Runtime Loop Tests
|
||||||
|
|
||||||
|
Add tests proving:
|
||||||
|
|
||||||
|
- updates received by the background loop reach callbacks
|
||||||
|
- no manual `ProcessIncomingAsync` call is needed in normal operation
|
||||||
|
|
||||||
|
### Recovery Tests
|
||||||
|
|
||||||
|
Add tests proving:
|
||||||
|
|
||||||
|
- EOF triggers reconnect
|
||||||
|
- reconnect replays handshake/connect/subscriptions
|
||||||
|
- callback dispatch resumes after reconnect
|
||||||
|
- writes during reconnect fail predictably
|
||||||
|
|
||||||
|
### Lifecycle Tests
|
||||||
|
|
||||||
|
Add tests proving:
|
||||||
|
|
||||||
|
- `DisconnectAsync` stops background tasks
|
||||||
|
- `DisposeAsync` stops reconnect attempts
|
||||||
|
- repeated failures do not start multiple reconnect loops
|
||||||
|
|
||||||
|
## Recommended Next Step
|
||||||
|
|
||||||
|
Create an implementation plan that breaks this into small tasks:
|
||||||
|
|
||||||
|
- durable subscription registry
|
||||||
|
- background receive loop
|
||||||
|
- reconnect loop and backoff
|
||||||
|
- replay logic
|
||||||
|
- runtime tests
|
||||||
519
docs/plans/2026-03-17-runtime-reconnect-implementation-plan.md
Normal file
519
docs/plans/2026-03-17-runtime-reconnect-implementation-plan.md
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
# SuiteLink Runtime Reconnect Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a background receive loop with automatic reconnect and subscription replay so the client continues dispatching updates after transport/session failures.
|
||||||
|
|
||||||
|
**Architecture:** The implementation extends `SuiteLinkClient` with a supervised runtime loop and reconnect flow while keeping durable subscription intent separate from ephemeral session mappings. Recovery rebuilds transport/session state, replays subscriptions, and resumes update dispatch without caller polling.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, C#, xUnit, `SemaphoreSlim`, `CancellationTokenSource`, existing SuiteLink codec/session/transport layers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Durable Subscription Registry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SubscriptionRegistrationEntry.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientSubscriptionRegistryTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task SubscribeAsync_StoresDurableSubscriptionIntent()
|
||||||
|
{
|
||||||
|
var client = TestClientFactory.CreateReadyClient();
|
||||||
|
|
||||||
|
await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||||
|
|
||||||
|
Assert.True(client.HasSubscription("Pump001.Run"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientSubscriptionRegistryTests -v minimal`
|
||||||
|
Expected: FAIL with missing durable registry behavior
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add a durable registry entry model storing:
|
||||||
|
|
||||||
|
- `ItemName`
|
||||||
|
- callback
|
||||||
|
- requested tag id
|
||||||
|
|
||||||
|
Store these entries in `SuiteLinkClient` separately from `SuiteLinkSession`.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientSubscriptionRegistryTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SubscriptionRegistrationEntry.cs /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientSubscriptionRegistryTests.cs
|
||||||
|
git commit -m "feat: add durable subscription registry"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Make Subscription Handles Remove Durable Intent
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SubscriptionHandle.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientSubscriptionRegistryTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task DisposingSubscription_RemovesDurableSubscriptionIntent()
|
||||||
|
{
|
||||||
|
var client = TestClientFactory.CreateReadyClient();
|
||||||
|
var handle = await client.SubscribeAsync("Pump001.Run", _ => { });
|
||||||
|
|
||||||
|
await handle.DisposeAsync();
|
||||||
|
|
||||||
|
Assert.False(client.HasSubscription("Pump001.Run"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter DisposingSubscription_RemovesDurableSubscriptionIntent -v minimal`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Ensure handle disposal removes durable registry entries even when wire unadvise cannot be sent.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientSubscriptionRegistryTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SubscriptionHandle.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientSubscriptionRegistryTests.cs
|
||||||
|
git commit -m "feat: persist subscription intent across reconnects"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Add Runtime State For Background Loop
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkSessionState.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientConnectionTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectAsync_TransitionsToReadyOnlyAfterRuntimeStarts()
|
||||||
|
{
|
||||||
|
var client = TestClientFactory.CreateReadyHandshakeClient();
|
||||||
|
|
||||||
|
await client.ConnectAsync(TestOptions.Create());
|
||||||
|
|
||||||
|
Assert.True(client.IsConnected);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter ConnectAsync_TransitionsToReadyOnlyAfterRuntimeStarts -v minimal`
|
||||||
|
Expected: FAIL with missing ready/runtime state
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
- `Ready`
|
||||||
|
- `Reconnecting`
|
||||||
|
|
||||||
|
and transition `ConnectAsync` into `Ready` when the runtime loop has been established.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientConnectionTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkSessionState.cs /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientConnectionTests.cs
|
||||||
|
git commit -m "feat: add ready and reconnecting runtime states"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Start Background Receive Loop
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientRuntimeLoopTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectAsync_StartsBackgroundLoop_AndDispatchesUpdateWithoutManualPolling()
|
||||||
|
{
|
||||||
|
var updateReceived = new TaskCompletionSource<SuiteLinkTagUpdate>();
|
||||||
|
var client = TestClientFactory.CreateClientWithQueuedUpdate(updateReceived);
|
||||||
|
|
||||||
|
await client.ConnectAsync(TestOptions.Create());
|
||||||
|
await client.SubscribeAsync("Pump001.Run", update => updateReceived.TrySetResult(update));
|
||||||
|
|
||||||
|
var update = await updateReceived.Task.WaitAsync(TimeSpan.FromSeconds(1));
|
||||||
|
Assert.True(update.Value.TryGetBoolean(out var value) && value);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientRuntimeLoopTests -v minimal`
|
||||||
|
Expected: FAIL because manual processing is still required
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Start a long-lived receive loop task after initial connect, and dispatch updates through existing session logic.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientRuntimeLoopTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientRuntimeLoopTests.cs
|
||||||
|
git commit -m "feat: add suitelink background receive loop"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Make ProcessIncomingAsync Internal Or Non-Primary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/README.md`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientRuntimeLoopTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing documentation/runtime check**
|
||||||
|
|
||||||
|
Define the intended runtime contract:
|
||||||
|
|
||||||
|
- normal operation uses background receive
|
||||||
|
- manual polling is not required for normal subscriptions
|
||||||
|
|
||||||
|
**Step 2: Run targeted tests**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientRuntimeLoopTests -v minimal`
|
||||||
|
Expected: PASS after Task 4
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Keep `ProcessIncomingAsync` only as an internal/test helper or document it as non-primary API.
|
||||||
|
|
||||||
|
**Step 4: Run test and docs verification**
|
||||||
|
|
||||||
|
Run: `rg -n "background receive|manual polling|ProcessIncomingAsync" /Users/dohertj2/Desktop/suitelinkclient/README.md`
|
||||||
|
Expected: PASS with updated wording
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/README.md
|
||||||
|
git commit -m "docs: describe background runtime model"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Detect EOF And Trigger Reconnect
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task ReceiveLoop_Eof_TransitionsToReconnecting()
|
||||||
|
{
|
||||||
|
var client = TestClientFactory.CreateClientThatEofsAfterConnect();
|
||||||
|
|
||||||
|
await client.ConnectAsync(TestOptions.Create());
|
||||||
|
|
||||||
|
await Eventually.AssertAsync(() => Assert.Equal(SuiteLinkSessionState.Reconnecting, client.DebugState));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientReconnectTests -v minimal`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Treat `ReceiveAsync == 0` as a disconnect trigger and start recovery.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientReconnectTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs
|
||||||
|
git commit -m "feat: detect disconnects and enter reconnect state"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: Add Bounded Reconnect Backoff
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconnect_UsesBoundedRetrySchedule()
|
||||||
|
{
|
||||||
|
var delays = new List<TimeSpan>();
|
||||||
|
var client = TestClientFactory.CreateReconnectTestClient(delays);
|
||||||
|
|
||||||
|
await client.ConnectAsync(TestOptions.Create());
|
||||||
|
|
||||||
|
Assert.Contains(TimeSpan.FromSeconds(1), delays);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter Reconnect_UsesBoundedRetrySchedule -v minimal`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add a small capped delay schedule:
|
||||||
|
|
||||||
|
- 0s
|
||||||
|
- 1s
|
||||||
|
- 2s
|
||||||
|
- 5s
|
||||||
|
- 10s capped
|
||||||
|
|
||||||
|
Inject delay behavior for tests if needed.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientReconnectTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs
|
||||||
|
git commit -m "feat: add bounded reconnect backoff"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8: Replay Subscriptions After Reconnect
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkSession.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconnect_ReplaysSubscriptions_AndRestoresDispatch()
|
||||||
|
{
|
||||||
|
var callbackCount = 0;
|
||||||
|
var client = TestClientFactory.CreateReconnectReplayClient(() => callbackCount++);
|
||||||
|
|
||||||
|
await client.ConnectAsync(TestOptions.Create());
|
||||||
|
await client.SubscribeAsync("Pump001.Run", _ => callbackCount++);
|
||||||
|
|
||||||
|
await client.WaitForReconnectReadyAsync();
|
||||||
|
Assert.True(callbackCount > 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter Reconnect_ReplaysSubscriptions_AndRestoresDispatch -v minimal`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
On successful reconnect:
|
||||||
|
|
||||||
|
- reset live session mappings
|
||||||
|
- replay all durable subscriptions one-by-one
|
||||||
|
- rebuild tag mappings from fresh ACKs
|
||||||
|
- return to `Ready`
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientReconnectTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/Internal/SuiteLinkSession.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientReconnectTests.cs
|
||||||
|
git commit -m "feat: replay subscriptions after reconnect"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 9: Reject Writes During Reconnect
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientWriteTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_DuringReconnect_ThrowsClearException()
|
||||||
|
{
|
||||||
|
var client = TestClientFactory.CreateReconnectingClient();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
client.WriteAsync("Pump001.Run", SuiteLinkValue.FromBoolean(true)));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter WriteAsync_DuringReconnect_ThrowsClearException -v minimal`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Guard `WriteAsync` so it succeeds only in `Ready`.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientWriteTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientWriteTests.cs
|
||||||
|
git commit -m "feat: reject writes while reconnecting"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 10: Stop Runtime Cleanly On Disconnect
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs`
|
||||||
|
- Test: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientConnectionTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task DisconnectAsync_StopsReceiveAndReconnectLoops()
|
||||||
|
{
|
||||||
|
var client = TestClientFactory.CreateRunningClient();
|
||||||
|
|
||||||
|
await client.ConnectAsync(TestOptions.Create());
|
||||||
|
await client.DisconnectAsync();
|
||||||
|
|
||||||
|
Assert.False(client.IsConnected);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter DisconnectAsync_StopsReceiveAndReconnectLoops -v minimal`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Cancel runtime loop tokens and stop reconnect attempts on disconnect/dispose.
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter SuiteLinkClientConnectionTests -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/src/SuiteLink.Client/SuiteLinkClient.cs /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.Tests/SuiteLinkClientConnectionTests.cs
|
||||||
|
git commit -m "feat: stop runtime loops on disconnect"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 11: Update README And Integration Docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/README.md`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests/README.md`
|
||||||
|
|
||||||
|
**Step 1: Write the failing documentation check**
|
||||||
|
|
||||||
|
Define required README terms:
|
||||||
|
|
||||||
|
- background receive loop
|
||||||
|
- automatic reconnect
|
||||||
|
- subscription replay
|
||||||
|
- writes rejected during reconnect
|
||||||
|
|
||||||
|
**Step 2: Run documentation review**
|
||||||
|
|
||||||
|
Run: `rg -n "background receive|automatic reconnect|subscription replay|reconnecting" /Users/dohertj2/Desktop/suitelinkclient/README.md /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests/README.md`
|
||||||
|
Expected: FAIL until docs are updated
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Update docs to describe the runtime model and recovery behavior honestly.
|
||||||
|
|
||||||
|
**Step 4: Run documentation review**
|
||||||
|
|
||||||
|
Run: `rg -n "background receive|automatic reconnect|subscription replay|reconnecting" /Users/dohertj2/Desktop/suitelinkclient/README.md /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests/README.md`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/README.md /Users/dohertj2/Desktop/suitelinkclient/tests/SuiteLink.Client.IntegrationTests/README.md
|
||||||
|
git commit -m "docs: describe runtime reconnect behavior"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 12: Full Verification Pass
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-17-runtime-reconnect-design.md`
|
||||||
|
- Modify: `/Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-17-runtime-reconnect-implementation-plan.md`
|
||||||
|
|
||||||
|
**Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx -v minimal`
|
||||||
|
Expected: PASS with integration harness still conditional by default
|
||||||
|
|
||||||
|
**Step 2: Run release build**
|
||||||
|
|
||||||
|
Run: `dotnet build /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx -c Release`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Run reconnect-focused tests**
|
||||||
|
|
||||||
|
Run: `dotnet test /Users/dohertj2/Desktop/suitelinkclient/SuiteLink.Client.slnx --filter Reconnect -v minimal`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Update plan notes if implementation deviated**
|
||||||
|
|
||||||
|
Add short notes to the design/plan docs if final runtime behavior differs from original assumptions.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add /Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-17-runtime-reconnect-design.md /Users/dohertj2/Desktop/suitelinkclient/docs/plans/2026-03-17-runtime-reconnect-implementation-plan.md
|
||||||
|
git commit -m "docs: finalize reconnect implementation verification"
|
||||||
|
```
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SuiteLink.Client.Internal;
|
||||||
|
|
||||||
|
internal sealed record SubscriptionRegistrationEntry(
|
||||||
|
string ItemName,
|
||||||
|
Action<SuiteLinkTagUpdate> OnUpdate,
|
||||||
|
uint RequestedTagId);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace SuiteLink.Client.Internal;
|
||||||
|
|
||||||
|
internal static class SuiteLinkRetryDelayCalculator
|
||||||
|
{
|
||||||
|
public static TimeSpan GetDelay(SuiteLinkRetryPolicy policy, int attempt, Func<double>? nextDouble = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(policy);
|
||||||
|
|
||||||
|
if (attempt < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt == 0)
|
||||||
|
{
|
||||||
|
return TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawSeconds = policy.InitialDelay.TotalSeconds * Math.Pow(policy.Multiplier, attempt - 1);
|
||||||
|
var boundedSeconds = Math.Min(rawSeconds, policy.MaxDelay.TotalSeconds);
|
||||||
|
if (!policy.UseJitter)
|
||||||
|
{
|
||||||
|
return TimeSpan.FromSeconds(boundedSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
var jitter = (nextDouble ?? Random.Shared.NextDouble).Invoke();
|
||||||
|
jitter = Math.Clamp(jitter, 0d, 1d);
|
||||||
|
return TimeSpan.FromSeconds(boundedSeconds * jitter);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,9 +148,18 @@ public sealed class SuiteLinkSession
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ClearSubscriptions()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_subscriptionsByItemName.Clear();
|
||||||
|
_subscriptionsByTagId.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool TryDispatchUpdate(DecodedUpdate decodedUpdate, DateTimeOffset receivedAtUtc, out SuiteLinkTagUpdate? dispatchedUpdate)
|
public bool TryDispatchUpdate(DecodedUpdate decodedUpdate, DateTimeOffset receivedAtUtc, out SuiteLinkTagUpdate? dispatchedUpdate)
|
||||||
{
|
{
|
||||||
return TryDispatchUpdate(decodedUpdate, receivedAtUtc, out dispatchedUpdate, out _);
|
return TryDispatchUpdate(decodedUpdate, receivedAtUtc, SuiteLinkUpdateSource.Live, out dispatchedUpdate, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryDispatchUpdate(
|
public bool TryDispatchUpdate(
|
||||||
@@ -158,6 +167,21 @@ public sealed class SuiteLinkSession
|
|||||||
DateTimeOffset receivedAtUtc,
|
DateTimeOffset receivedAtUtc,
|
||||||
out SuiteLinkTagUpdate? dispatchedUpdate,
|
out SuiteLinkTagUpdate? dispatchedUpdate,
|
||||||
out Exception? callbackException)
|
out Exception? callbackException)
|
||||||
|
{
|
||||||
|
return TryDispatchUpdate(
|
||||||
|
decodedUpdate,
|
||||||
|
receivedAtUtc,
|
||||||
|
SuiteLinkUpdateSource.Live,
|
||||||
|
out dispatchedUpdate,
|
||||||
|
out callbackException);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDispatchUpdate(
|
||||||
|
DecodedUpdate decodedUpdate,
|
||||||
|
DateTimeOffset receivedAtUtc,
|
||||||
|
SuiteLinkUpdateSource source,
|
||||||
|
out SuiteLinkTagUpdate? dispatchedUpdate,
|
||||||
|
out Exception? callbackException)
|
||||||
{
|
{
|
||||||
Action<SuiteLinkTagUpdate>? callback;
|
Action<SuiteLinkTagUpdate>? callback;
|
||||||
string itemName;
|
string itemName;
|
||||||
@@ -181,7 +205,8 @@ public sealed class SuiteLinkSession
|
|||||||
decodedUpdate.Value,
|
decodedUpdate.Value,
|
||||||
decodedUpdate.Quality,
|
decodedUpdate.Quality,
|
||||||
decodedUpdate.ElapsedMilliseconds,
|
decodedUpdate.ElapsedMilliseconds,
|
||||||
receivedAtUtc);
|
receivedAtUtc,
|
||||||
|
source);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -215,11 +240,17 @@ public sealed class SuiteLinkSession
|
|||||||
return (currentState, nextState) switch
|
return (currentState, nextState) switch
|
||||||
{
|
{
|
||||||
(SuiteLinkSessionState.Disconnected, SuiteLinkSessionState.TcpConnected) => true,
|
(SuiteLinkSessionState.Disconnected, SuiteLinkSessionState.TcpConnected) => true,
|
||||||
|
(SuiteLinkSessionState.Reconnecting, SuiteLinkSessionState.TcpConnected) => true,
|
||||||
(SuiteLinkSessionState.TcpConnected, SuiteLinkSessionState.HandshakeComplete) => true,
|
(SuiteLinkSessionState.TcpConnected, SuiteLinkSessionState.HandshakeComplete) => true,
|
||||||
(SuiteLinkSessionState.HandshakeComplete, SuiteLinkSessionState.ConnectSent) => true,
|
(SuiteLinkSessionState.HandshakeComplete, SuiteLinkSessionState.ConnectSent) => true,
|
||||||
(SuiteLinkSessionState.ConnectSent, SuiteLinkSessionState.SessionConnected) => true,
|
(SuiteLinkSessionState.ConnectSent, SuiteLinkSessionState.Ready) => true,
|
||||||
(SuiteLinkSessionState.SessionConnected, SuiteLinkSessionState.Subscribed) => true,
|
(SuiteLinkSessionState.Ready, SuiteLinkSessionState.Subscribed) => true,
|
||||||
(SuiteLinkSessionState.Subscribed, SuiteLinkSessionState.SessionConnected) => true,
|
(SuiteLinkSessionState.Subscribed, SuiteLinkSessionState.Ready) => true,
|
||||||
|
(SuiteLinkSessionState.TcpConnected, SuiteLinkSessionState.Reconnecting) => true,
|
||||||
|
(SuiteLinkSessionState.HandshakeComplete, SuiteLinkSessionState.Reconnecting) => true,
|
||||||
|
(SuiteLinkSessionState.ConnectSent, SuiteLinkSessionState.Reconnecting) => true,
|
||||||
|
(SuiteLinkSessionState.Ready, SuiteLinkSessionState.Reconnecting) => true,
|
||||||
|
(SuiteLinkSessionState.Subscribed, SuiteLinkSessionState.Reconnecting) => true,
|
||||||
(_, SuiteLinkSessionState.Disconnected) => true,
|
(_, SuiteLinkSessionState.Disconnected) => true,
|
||||||
(_, SuiteLinkSessionState.Faulted) => true,
|
(_, SuiteLinkSessionState.Faulted) => true,
|
||||||
_ => false
|
_ => false
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ public enum SuiteLinkSessionState
|
|||||||
TcpConnected = 1,
|
TcpConnected = 1,
|
||||||
HandshakeComplete = 2,
|
HandshakeComplete = 2,
|
||||||
ConnectSent = 3,
|
ConnectSent = 3,
|
||||||
SessionConnected = 4,
|
Ready = 4,
|
||||||
Subscribed = 5,
|
Subscribed = 5,
|
||||||
Faulted = 6
|
Reconnecting = 6,
|
||||||
|
Faulted = 7
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/SuiteLink.Client/Properties/AssemblyInfo.cs
Normal file
3
src/SuiteLink.Client/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("SuiteLink.Client.Tests")]
|
||||||
@@ -19,7 +19,7 @@ public sealed class SubscriptionHandle : IAsyncDisposable
|
|||||||
|
|
||||||
public uint TagId { get; }
|
public uint TagId { get; }
|
||||||
|
|
||||||
public bool IsDisposed => _disposeState == 1;
|
public bool IsDisposed => Volatile.Read(ref _disposeState) == 1;
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
7
src/SuiteLink.Client/SuiteLinkCatchUpPolicy.cs
Normal file
7
src/SuiteLink.Client/SuiteLinkCatchUpPolicy.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SuiteLink.Client;
|
||||||
|
|
||||||
|
public enum SuiteLinkCatchUpPolicy
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
RefreshLatestValue = 1
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
using SuiteLink.Client.Internal;
|
using SuiteLink.Client.Internal;
|
||||||
using SuiteLink.Client.Protocol;
|
using SuiteLink.Client.Protocol;
|
||||||
using SuiteLink.Client.Transport;
|
using SuiteLink.Client.Transport;
|
||||||
@@ -8,12 +9,25 @@ public sealed class SuiteLinkClient : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
private readonly ISuiteLinkTransport _transport;
|
private readonly ISuiteLinkTransport _transport;
|
||||||
private readonly bool _ownsTransport;
|
private readonly bool _ownsTransport;
|
||||||
|
private readonly Func<TimeSpan, CancellationToken, Task> _delayAsync;
|
||||||
|
private readonly Func<CancellationToken, ValueTask<bool>>? _reconnectAttemptAsyncOverride;
|
||||||
private readonly SemaphoreSlim _connectGate = new(1, 1);
|
private readonly SemaphoreSlim _connectGate = new(1, 1);
|
||||||
private readonly SemaphoreSlim _operationGate = new(1, 1);
|
private readonly SemaphoreSlim _operationGate = new(1, 1);
|
||||||
private readonly SuiteLinkSession _session = new();
|
private readonly SuiteLinkSession _session = new();
|
||||||
|
private readonly object _durableSubscriptionsSync = new();
|
||||||
|
private readonly Dictionary<string, SubscriptionRegistrationEntry> _durableSubscriptions =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
private static readonly TimeSpan RuntimeLoopIdleDelay = TimeSpan.FromMilliseconds(25);
|
||||||
|
private static readonly TimeSpan RuntimeLoopPollInterval = TimeSpan.FromMilliseconds(50);
|
||||||
private byte[] _receiveBuffer = new byte[1024];
|
private byte[] _receiveBuffer = new byte[1024];
|
||||||
private int _receiveCount;
|
private int _receiveCount;
|
||||||
private int _nextSubscriptionTagId;
|
private int _nextSubscriptionTagId;
|
||||||
|
private CancellationTokenSource? _runtimeReceiveLoopCts;
|
||||||
|
private Task? _runtimeReceiveLoopTask;
|
||||||
|
private CancellationTokenSource? _reconnectLoopCts;
|
||||||
|
private Task? _reconnectLoopTask;
|
||||||
|
private SuiteLinkConnectionOptions? _connectionOptions;
|
||||||
|
private bool _runtimeLoopEstablished;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public SuiteLinkClient()
|
public SuiteLinkClient()
|
||||||
@@ -22,14 +36,53 @@ public sealed class SuiteLinkClient : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
public SuiteLinkClient(ISuiteLinkTransport transport, bool ownsTransport = false)
|
public SuiteLinkClient(ISuiteLinkTransport transport, bool ownsTransport = false)
|
||||||
|
: this(transport, ownsTransport, delayAsync: null, reconnectAttemptAsync: null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal SuiteLinkClient(
|
||||||
|
ISuiteLinkTransport transport,
|
||||||
|
bool ownsTransport,
|
||||||
|
Func<TimeSpan, CancellationToken, Task>? delayAsync,
|
||||||
|
Func<CancellationToken, ValueTask<bool>>? reconnectAttemptAsync = null)
|
||||||
{
|
{
|
||||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||||
_ownsTransport = ownsTransport;
|
_ownsTransport = ownsTransport;
|
||||||
|
_delayAsync = delayAsync ?? DelayAsync;
|
||||||
|
_reconnectAttemptAsyncOverride = reconnectAttemptAsync;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsConnected =>
|
public bool IsConnected =>
|
||||||
!_disposed &&
|
!_disposed &&
|
||||||
_session.State is SuiteLinkSessionState.SessionConnected or SuiteLinkSessionState.Subscribed;
|
_session.State is SuiteLinkSessionState.Ready or SuiteLinkSessionState.Subscribed;
|
||||||
|
|
||||||
|
internal SuiteLinkSessionState DebugState => _session.State;
|
||||||
|
|
||||||
|
internal bool DebugHasDurableSubscription(string itemName)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(itemName);
|
||||||
|
|
||||||
|
lock (_durableSubscriptionsSync)
|
||||||
|
{
|
||||||
|
return _durableSubscriptions.ContainsKey(itemName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task DebugHoldOperationGateAsync(Task releaseSignal, TaskCompletionSource<bool>? acquiredSignal = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(releaseSignal);
|
||||||
|
|
||||||
|
await _operationGate.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
acquiredSignal?.TrySetResult(true);
|
||||||
|
await releaseSignal.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_operationGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task ConnectAsync(SuiteLinkConnectionOptions options, CancellationToken cancellationToken = default)
|
public async Task ConnectAsync(SuiteLinkConnectionOptions options, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -51,23 +104,11 @@ public sealed class SuiteLinkClient : IAsyncDisposable
|
|||||||
throw new InvalidOperationException("Client is faulted and cannot be reused.");
|
throw new InvalidOperationException("Client is faulted and cannot be reused.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_connectionOptions = options;
|
||||||
await _transport.ConnectAsync(options.Host, options.Port, cancellationToken).ConfigureAwait(false);
|
await _transport.ConnectAsync(options.Host, options.Port, cancellationToken).ConfigureAwait(false);
|
||||||
_session.SetState(SuiteLinkSessionState.TcpConnected);
|
_session.SetState(SuiteLinkSessionState.TcpConnected);
|
||||||
|
await SendStartupSequenceAsync(options, cancellationToken).ConfigureAwait(false);
|
||||||
var handshakeBytes = SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake(
|
await StartBackgroundReceiveLoopAsync(cancellationToken).ConfigureAwait(false);
|
||||||
options.Application,
|
|
||||||
options.ClientNode,
|
|
||||||
options.UserName);
|
|
||||||
await _transport.SendAsync(handshakeBytes, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var handshakeAckBytes = await ReceiveSingleFrameAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
_ = SuiteLinkHandshakeCodec.ParseNormalHandshakeAck(handshakeAckBytes);
|
|
||||||
_session.SetState(SuiteLinkSessionState.HandshakeComplete);
|
|
||||||
|
|
||||||
var connectBytes = SuiteLinkConnectCodec.Encode(options);
|
|
||||||
await _transport.SendAsync(connectBytes, cancellationToken).ConfigureAwait(false);
|
|
||||||
// At this stage we've only submitted CONNECT. Do not report ready yet.
|
|
||||||
_session.SetState(SuiteLinkSessionState.ConnectSent);
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -234,10 +275,21 @@ public sealed class SuiteLinkClient : IAsyncDisposable
|
|||||||
ArgumentException.ThrowIfNullOrWhiteSpace(itemName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(itemName);
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
if (_session.State == SuiteLinkSessionState.Reconnecting)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Client is reconnecting and cannot accept writes until the session is ready.");
|
||||||
|
}
|
||||||
|
|
||||||
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
if (_session.State == SuiteLinkSessionState.Reconnecting)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Client is reconnecting and cannot accept writes until the session is ready.");
|
||||||
|
}
|
||||||
|
|
||||||
EnsureTagOperationsAllowed();
|
EnsureTagOperationsAllowed();
|
||||||
|
|
||||||
if (!_session.TryGetTagId(itemName, out var tagId))
|
if (!_session.TryGetTagId(itemName, out var tagId))
|
||||||
@@ -260,6 +312,345 @@ public sealed class SuiteLinkClient : IAsyncDisposable
|
|||||||
await DisposeCoreAsync(CancellationToken.None).ConfigureAwait(false);
|
await DisposeCoreAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task StartBackgroundReceiveLoopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_runtimeReceiveLoopTask is not null && !_runtimeReceiveLoopTask.IsCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtimeLoopCts = new CancellationTokenSource();
|
||||||
|
var startedSignal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
_runtimeLoopEstablished = false;
|
||||||
|
_runtimeReceiveLoopCts = runtimeLoopCts;
|
||||||
|
_runtimeReceiveLoopTask = RunBackgroundReceiveLoopAsync(startedSignal, runtimeLoopCts);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await startedSignal.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await StopBackgroundReceiveLoopAsync().ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopBackgroundReceiveLoopAsync()
|
||||||
|
{
|
||||||
|
var runtimeLoopCts = _runtimeReceiveLoopCts;
|
||||||
|
var runtimeLoopTask = _runtimeReceiveLoopTask;
|
||||||
|
_runtimeReceiveLoopCts = null;
|
||||||
|
_runtimeReceiveLoopTask = null;
|
||||||
|
|
||||||
|
if (runtimeLoopCts is null && runtimeLoopTask is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeLoopCts?.Cancel();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (runtimeLoopTask is not null)
|
||||||
|
{
|
||||||
|
await runtimeLoopTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Runtime-loop failures are handled in-loop via session faulting.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartReconnectLoopIfNeeded()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_reconnectLoopTask is not null && !_reconnectLoopTask.IsCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reconnectLoopCts = new CancellationTokenSource();
|
||||||
|
_reconnectLoopCts = reconnectLoopCts;
|
||||||
|
_reconnectLoopTask = RunReconnectLoopAsync(reconnectLoopCts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopReconnectLoopAsync()
|
||||||
|
{
|
||||||
|
var reconnectLoopCts = _reconnectLoopCts;
|
||||||
|
var reconnectLoopTask = _reconnectLoopTask;
|
||||||
|
_reconnectLoopCts = null;
|
||||||
|
_reconnectLoopTask = null;
|
||||||
|
|
||||||
|
if (reconnectLoopCts is null && reconnectLoopTask is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectLoopCts?.Cancel();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (reconnectLoopTask is not null)
|
||||||
|
{
|
||||||
|
await reconnectLoopTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Reconnect-loop failures are handled in-loop via session state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EstablishRuntimeLoopStateIfNeeded()
|
||||||
|
{
|
||||||
|
if (_runtimeLoopEstablished)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_session.TryTransitionState(SuiteLinkSessionState.ConnectSent, SuiteLinkSessionState.Ready))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_runtimeLoopEstablished = true;
|
||||||
|
if (_session.SubscriptionCount > 0 && _session.State == SuiteLinkSessionState.Ready)
|
||||||
|
{
|
||||||
|
_session.SetState(SuiteLinkSessionState.Subscribed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunBackgroundReceiveLoopAsync(
|
||||||
|
TaskCompletionSource<bool> startedSignal,
|
||||||
|
CancellationTokenSource runtimeLoopCts)
|
||||||
|
{
|
||||||
|
var cancellationToken = runtimeLoopCts.Token;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EstablishRuntimeLoopStateIfNeeded();
|
||||||
|
startedSignal.TrySetResult(true);
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_session.SubscriptionCount == 0)
|
||||||
|
{
|
||||||
|
// Task 6 policy: in zero-subscription Ready mode, no background liveness probe is issued.
|
||||||
|
// Disconnect is detected on the next receive-bound operation (subscribe/read/manual process).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(RuntimeLoopIdleDelay, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<DecodedUpdate> decodedUpdates = [];
|
||||||
|
var dispatchUpdates = false;
|
||||||
|
var operationGateHeld = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
operationGateHeld = await _operationGate
|
||||||
|
.WaitAsync(RuntimeLoopPollInterval, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!operationGateHeld)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
pollCts.CancelAfter(RuntimeLoopPollInterval);
|
||||||
|
|
||||||
|
decodedUpdates = await ProcessSingleIncomingFrameAsync(pollCts.Token).ConfigureAwait(false);
|
||||||
|
EstablishRuntimeLoopStateIfNeeded();
|
||||||
|
dispatchUpdates = decodedUpdates.Count > 0;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Poll timeout: keep the runtime receive loop alive.
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or SocketException)
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
TransitionToReconnecting();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_session.SetState(SuiteLinkSessionState.Faulted);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Preserve original runtime loop failure behavior.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (operationGateHeld)
|
||||||
|
{
|
||||||
|
_operationGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatchUpdates)
|
||||||
|
{
|
||||||
|
DispatchDecodedUpdates(decodedUpdates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_runtimeReceiveLoopCts, runtimeLoopCts))
|
||||||
|
{
|
||||||
|
_runtimeReceiveLoopCts = null;
|
||||||
|
_runtimeReceiveLoopTask = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeLoopCts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunReconnectLoopAsync(CancellationTokenSource reconnectLoopCts)
|
||||||
|
{
|
||||||
|
var cancellationToken = reconnectLoopCts.Token;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var attempt = 0;
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reconnectDelay = GetReconnectDelay(attempt);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _delayAsync(reconnectDelay, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_disposed || cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool reconnectSucceeded;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reconnectSucceeded = await AttemptReconnectAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
reconnectSucceeded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectSucceeded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checked
|
||||||
|
{
|
||||||
|
attempt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_reconnectLoopCts, reconnectLoopCts))
|
||||||
|
{
|
||||||
|
_reconnectLoopCts = null;
|
||||||
|
_reconnectLoopTask = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectLoopCts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TransitionToReconnecting()
|
||||||
|
{
|
||||||
|
if (_session.TryTransitionState(SuiteLinkSessionState.Subscribed, SuiteLinkSessionState.Reconnecting))
|
||||||
|
{
|
||||||
|
_runtimeLoopEstablished = false;
|
||||||
|
StartReconnectLoopIfNeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_session.TryTransitionState(SuiteLinkSessionState.Ready, SuiteLinkSessionState.Reconnecting))
|
||||||
|
{
|
||||||
|
_runtimeLoopEstablished = false;
|
||||||
|
StartReconnectLoopIfNeeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSpan GetReconnectDelay(int attempt)
|
||||||
|
{
|
||||||
|
if (attempt < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
var retryPolicy = _connectionOptions?.Runtime.RetryPolicy ?? SuiteLinkRetryPolicy.Default;
|
||||||
|
return SuiteLinkRetryDelayCalculator.GetDelay(retryPolicy, attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (delay <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.Delay(delay, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ValueTask<bool> AttemptReconnectAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_reconnectAttemptAsyncOverride is not null)
|
||||||
|
{
|
||||||
|
return _reconnectAttemptAsyncOverride(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttemptReconnectCoreAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask<byte[]> ReceiveSingleFrameAsync(CancellationToken cancellationToken)
|
private async ValueTask<byte[]> ReceiveSingleFrameAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
while (true)
|
while (true)
|
||||||
@@ -324,18 +715,23 @@ public sealed class SuiteLinkClient : IAsyncDisposable
|
|||||||
await _connectGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _connectGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
connectGateHeld = true;
|
connectGateHeld = true;
|
||||||
|
|
||||||
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
operationGateHeld = true;
|
|
||||||
|
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
await StopBackgroundReceiveLoopAsync().ConfigureAwait(false);
|
||||||
|
await StopReconnectLoopAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
operationGateHeld = true;
|
||||||
|
|
||||||
|
_runtimeLoopEstablished = false;
|
||||||
_session.SetState(SuiteLinkSessionState.Disconnected);
|
_session.SetState(SuiteLinkSessionState.Disconnected);
|
||||||
_receiveCount = 0;
|
_receiveCount = 0;
|
||||||
_receiveBuffer = new byte[1024];
|
_receiveBuffer = new byte[1024];
|
||||||
|
_connectionOptions = null;
|
||||||
|
|
||||||
if (_ownsTransport)
|
if (_ownsTransport)
|
||||||
{
|
{
|
||||||
@@ -366,50 +762,33 @@ public sealed class SuiteLinkClient : IAsyncDisposable
|
|||||||
|
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
EnsureTagOperationsAllowed();
|
EnsureTagOperationsAllowed();
|
||||||
|
lock (_durableSubscriptionsSync)
|
||||||
|
{
|
||||||
|
if (_durableSubscriptions.ContainsKey(itemName))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Tag '{itemName}' is already subscribed. Dispose the existing subscription before subscribing again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var requestedTagId = unchecked((uint)Interlocked.Increment(ref _nextSubscriptionTagId));
|
var requestedTagId = unchecked((uint)Interlocked.Increment(ref _nextSubscriptionTagId));
|
||||||
var adviseBytes = SuiteLinkSubscriptionCodec.EncodeAdvise(requestedTagId, itemName);
|
var deferredUpdates = await AdviseAndRegisterSubscriptionAsync(
|
||||||
await _transport.SendAsync(adviseBytes, cancellationToken).ConfigureAwait(false);
|
itemName,
|
||||||
|
onUpdate,
|
||||||
var adviseAckResult = await ReceiveAndCollectUpdatesUntilAsync(
|
requestedTagId,
|
||||||
messageType => messageType == SuiteLinkSubscriptionCodec.AdviseAckMessageType,
|
storeDurableSubscription: true,
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
var adviseAckBytes = adviseAckResult.FrameBytes;
|
|
||||||
|
|
||||||
var ackItems = SuiteLinkSubscriptionCodec.DecodeAdviseAckMany(adviseAckBytes);
|
|
||||||
if (ackItems.Count != 1)
|
|
||||||
{
|
|
||||||
throw new FormatException(
|
|
||||||
$"Expected exactly one advise ACK item for a single subscribe request, but decoded {ackItems.Count}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var acknowledgedTagId = ackItems[0].TagId;
|
|
||||||
if (acknowledgedTagId != requestedTagId)
|
|
||||||
{
|
|
||||||
throw new FormatException(
|
|
||||||
$"Advise ACK tag id 0x{acknowledgedTagId:x8} did not match requested tag id 0x{requestedTagId:x8}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
_session.RegisterSubscription(itemName, acknowledgedTagId, onUpdate);
|
|
||||||
if (_session.State == SuiteLinkSessionState.ConnectSent)
|
|
||||||
{
|
|
||||||
_session.SetState(SuiteLinkSessionState.SessionConnected);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_session.State == SuiteLinkSessionState.SessionConnected)
|
|
||||||
{
|
|
||||||
_session.SetState(SuiteLinkSessionState.Subscribed);
|
|
||||||
}
|
|
||||||
|
|
||||||
var handle = new SubscriptionHandle(
|
var handle = new SubscriptionHandle(
|
||||||
itemName,
|
itemName,
|
||||||
acknowledgedTagId,
|
requestedTagId,
|
||||||
() => UnsubscribeAsync(acknowledgedTagId, CancellationToken.None));
|
() => UnsubscribeAsync(itemName, requestedTagId, CancellationToken.None));
|
||||||
return new SubscriptionRegistration(handle, adviseAckResult.DeferredUpdates);
|
return new SubscriptionRegistration(handle, deferredUpdates);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask UnsubscribeAsync(uint tagId, CancellationToken cancellationToken)
|
private async ValueTask UnsubscribeAsync(string itemName, uint tagId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
RemoveDurableSubscription(itemName);
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -438,10 +817,209 @@ public sealed class SuiteLinkClient : IAsyncDisposable
|
|||||||
|
|
||||||
if (_session.State == SuiteLinkSessionState.Subscribed && _session.SubscriptionCount == 0)
|
if (_session.State == SuiteLinkSessionState.Subscribed && _session.SubscriptionCount == 0)
|
||||||
{
|
{
|
||||||
_session.SetState(SuiteLinkSessionState.SessionConnected);
|
_session.SetState(SuiteLinkSessionState.Ready);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RemoveDurableSubscription(string itemName)
|
||||||
|
{
|
||||||
|
lock (_durableSubscriptionsSync)
|
||||||
|
{
|
||||||
|
_durableSubscriptions.Remove(itemName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendStartupSequenceAsync(
|
||||||
|
SuiteLinkConnectionOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var handshakeBytes = SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake(
|
||||||
|
options.Application,
|
||||||
|
options.ClientNode,
|
||||||
|
options.UserName);
|
||||||
|
await _transport.SendAsync(handshakeBytes, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var handshakeAckBytes = await ReceiveSingleFrameAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
_ = SuiteLinkHandshakeCodec.ParseNormalHandshakeAck(handshakeAckBytes);
|
||||||
|
_session.SetState(SuiteLinkSessionState.HandshakeComplete);
|
||||||
|
|
||||||
|
var connectBytes = SuiteLinkConnectCodec.Encode(options);
|
||||||
|
await _transport.SendAsync(connectBytes, cancellationToken).ConfigureAwait(false);
|
||||||
|
_session.SetState(SuiteLinkSessionState.ConnectSent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<bool> AttemptReconnectCoreAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var options = _connectionOptions;
|
||||||
|
if (options is null || _disposed)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectGateHeld = false;
|
||||||
|
var operationGateHeld = false;
|
||||||
|
List<DecodedUpdate>? deferredUpdates = null;
|
||||||
|
List<PendingSyntheticDispatch>? catchUpUpdates = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _connectGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
connectGateHeld = true;
|
||||||
|
|
||||||
|
if (_disposed || _session.State != SuiteLinkSessionState.Reconnecting)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
operationGateHeld = true;
|
||||||
|
|
||||||
|
_runtimeLoopEstablished = false;
|
||||||
|
_receiveCount = 0;
|
||||||
|
_receiveBuffer = new byte[1024];
|
||||||
|
_session.ClearSubscriptions();
|
||||||
|
await ResetTransportForReconnectAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await _transport.ConnectAsync(options.Host, options.Port, cancellationToken).ConfigureAwait(false);
|
||||||
|
_session.SetState(SuiteLinkSessionState.TcpConnected);
|
||||||
|
await SendStartupSequenceAsync(options, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
deferredUpdates = [];
|
||||||
|
var durableSubscriptions = SnapshotDurableSubscriptions();
|
||||||
|
foreach (var registration in durableSubscriptions)
|
||||||
|
{
|
||||||
|
var replayedUpdates = await AdviseAndRegisterSubscriptionAsync(
|
||||||
|
registration.ItemName,
|
||||||
|
registration.OnUpdate,
|
||||||
|
registration.RequestedTagId,
|
||||||
|
storeDurableSubscription: false,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
deferredUpdates.AddRange(replayedUpdates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.Runtime.CatchUpPolicy == SuiteLinkCatchUpPolicy.RefreshLatestValue)
|
||||||
|
{
|
||||||
|
catchUpUpdates = [];
|
||||||
|
foreach (var registration in durableSubscriptions)
|
||||||
|
{
|
||||||
|
var catchUpUpdate = await TryRefreshLatestValueAsync(
|
||||||
|
registration,
|
||||||
|
options.Runtime.CatchUpTimeout,
|
||||||
|
deferredUpdates,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (catchUpUpdate is { } pendingDispatch)
|
||||||
|
{
|
||||||
|
catchUpUpdates.Add(pendingDispatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await StartBackgroundReceiveLoopAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (!_disposed && _session.State != SuiteLinkSessionState.Reconnecting)
|
||||||
|
{
|
||||||
|
_session.SetState(SuiteLinkSessionState.Reconnecting);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (operationGateHeld)
|
||||||
|
{
|
||||||
|
_operationGate.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectGateHeld)
|
||||||
|
{
|
||||||
|
_connectGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deferredUpdates is not null)
|
||||||
|
{
|
||||||
|
DispatchDecodedUpdates(deferredUpdates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catchUpUpdates is not null)
|
||||||
|
{
|
||||||
|
DispatchSyntheticUpdates(catchUpUpdates);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask ResetTransportForReconnectAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_transport is not ISuiteLinkReconnectableTransport reconnectableTransport)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reconnectableTransport.ResetConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubscriptionRegistrationEntry[] SnapshotDurableSubscriptions()
|
||||||
|
{
|
||||||
|
lock (_durableSubscriptionsSync)
|
||||||
|
{
|
||||||
|
return [.. _durableSubscriptions.Values.OrderBy(entry => entry.RequestedTagId)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<DecodedUpdate>> AdviseAndRegisterSubscriptionAsync(
|
||||||
|
string itemName,
|
||||||
|
Action<SuiteLinkTagUpdate> onUpdate,
|
||||||
|
uint requestedTagId,
|
||||||
|
bool storeDurableSubscription,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var adviseBytes = SuiteLinkSubscriptionCodec.EncodeAdvise(requestedTagId, itemName);
|
||||||
|
await _transport.SendAsync(adviseBytes, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var adviseAckResult = await ReceiveAndCollectUpdatesUntilAsync(
|
||||||
|
messageType => messageType == SuiteLinkSubscriptionCodec.AdviseAckMessageType,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
var adviseAckBytes = adviseAckResult.FrameBytes;
|
||||||
|
|
||||||
|
var ackItems = SuiteLinkSubscriptionCodec.DecodeAdviseAckMany(adviseAckBytes);
|
||||||
|
if (ackItems.Count != 1)
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"Expected exactly one advise ACK item for a single subscribe request, but decoded {ackItems.Count}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var acknowledgedTagId = ackItems[0].TagId;
|
||||||
|
if (acknowledgedTagId != requestedTagId)
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"Advise ACK tag id 0x{acknowledgedTagId:x8} did not match requested tag id 0x{requestedTagId:x8}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_session.RegisterSubscription(itemName, acknowledgedTagId, onUpdate);
|
||||||
|
if (storeDurableSubscription)
|
||||||
|
{
|
||||||
|
lock (_durableSubscriptionsSync)
|
||||||
|
{
|
||||||
|
_durableSubscriptions[itemName] =
|
||||||
|
new SubscriptionRegistrationEntry(itemName, onUpdate, requestedTagId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_session.State == SuiteLinkSessionState.Ready)
|
||||||
|
{
|
||||||
|
_session.SetState(SuiteLinkSessionState.Subscribed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return adviseAckResult.DeferredUpdates;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<FrameReadResult> ReceiveAndCollectUpdatesUntilAsync(
|
private async Task<FrameReadResult> ReceiveAndCollectUpdatesUntilAsync(
|
||||||
Func<ushort, bool> messageTypePredicate,
|
Func<ushort, bool> messageTypePredicate,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -464,6 +1042,114 @@ public sealed class SuiteLinkClient : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<PendingSyntheticDispatch?> TryRefreshLatestValueAsync(
|
||||||
|
SubscriptionRegistrationEntry registration,
|
||||||
|
TimeSpan timeout,
|
||||||
|
List<DecodedUpdate> passthroughUpdates,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeoutCts.CancelAfter(timeout);
|
||||||
|
|
||||||
|
var requestedTagId = unchecked((uint)Interlocked.Increment(ref _nextSubscriptionTagId));
|
||||||
|
var adviseBytes = SuiteLinkSubscriptionCodec.EncodeAdvise(requestedTagId, registration.ItemName);
|
||||||
|
await _transport.SendAsync(adviseBytes, timeoutCts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
DecodedUpdate? matchedUpdate = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var adviseAckResult = await ReceiveAndCollectUpdatesUntilAsync(
|
||||||
|
messageType => messageType == SuiteLinkSubscriptionCodec.AdviseAckMessageType,
|
||||||
|
timeoutCts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var ackItems = SuiteLinkSubscriptionCodec.DecodeAdviseAckMany(adviseAckResult.FrameBytes);
|
||||||
|
if (ackItems.Count != 1 || ackItems[0].TagId != requestedTagId)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedUpdate = ExtractMatchedUpdate(requestedTagId, adviseAckResult.DeferredUpdates, passthroughUpdates);
|
||||||
|
while (matchedUpdate is null)
|
||||||
|
{
|
||||||
|
var frameBytes = await ReceiveSingleFrameAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||||
|
var frame = SuiteLinkFrameReader.ParseFrame(frameBytes);
|
||||||
|
if (frame.MessageType != SuiteLinkUpdateCodec.UpdateMessageType)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedUpdate = ExtractMatchedUpdate(
|
||||||
|
requestedTagId,
|
||||||
|
SuiteLinkUpdateCodec.DecodeMany(frameBytes),
|
||||||
|
passthroughUpdates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var unadviseBytes = SuiteLinkSubscriptionCodec.EncodeUnadvise(requestedTagId);
|
||||||
|
await _transport.SendAsync(unadviseBytes, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort cleanup for temporary catch-up subscriptions.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedUpdate is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PendingSyntheticDispatch(
|
||||||
|
registration.OnUpdate,
|
||||||
|
new SuiteLinkTagUpdate(
|
||||||
|
registration.ItemName,
|
||||||
|
ResolveLiveTagId(registration.ItemName),
|
||||||
|
matchedUpdate.Value.Value,
|
||||||
|
matchedUpdate.Value.Quality,
|
||||||
|
matchedUpdate.Value.ElapsedMilliseconds,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
SuiteLinkUpdateSource.CatchUpReplay));
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint ResolveLiveTagId(string itemName)
|
||||||
|
{
|
||||||
|
if (_session.TryGetTagId(itemName, out var liveTagId))
|
||||||
|
{
|
||||||
|
return liveTagId;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Live tag mapping for '{itemName}' was not available during catch-up replay.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DecodedUpdate? ExtractMatchedUpdate(
|
||||||
|
uint requestedTagId,
|
||||||
|
IReadOnlyList<DecodedUpdate> decodedUpdates,
|
||||||
|
List<DecodedUpdate> passthroughUpdates)
|
||||||
|
{
|
||||||
|
DecodedUpdate? matched = null;
|
||||||
|
foreach (var decodedUpdate in decodedUpdates)
|
||||||
|
{
|
||||||
|
if (decodedUpdate.TagId == requestedTagId && matched is null)
|
||||||
|
{
|
||||||
|
matched = decodedUpdate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
passthroughUpdates.Add(decodedUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<DecodedUpdate>> ProcessSingleIncomingFrameAsync(CancellationToken cancellationToken)
|
private async Task<IReadOnlyList<DecodedUpdate>> ProcessSingleIncomingFrameAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var frameBytes = await ReceiveSingleFrameAsync(cancellationToken).ConfigureAwait(false);
|
var frameBytes = await ReceiveSingleFrameAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -490,11 +1176,32 @@ public sealed class SuiteLinkClient : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void DispatchSyntheticUpdates(IReadOnlyList<PendingSyntheticDispatch> updates)
|
||||||
|
{
|
||||||
|
if (updates.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var update in updates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
update.OnUpdate(update.Update);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Synthetic catch-up callback failures should not tear down reconnect recovery.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureTagOperationsAllowed()
|
private void EnsureTagOperationsAllowed()
|
||||||
{
|
{
|
||||||
|
// Pre-runtime-loop phase: tag operations still use explicit/on-demand reads after CONNECT.
|
||||||
if (_session.State is
|
if (_session.State is
|
||||||
not SuiteLinkSessionState.ConnectSent and
|
not SuiteLinkSessionState.ConnectSent and
|
||||||
not SuiteLinkSessionState.SessionConnected and
|
not SuiteLinkSessionState.Ready and
|
||||||
not SuiteLinkSessionState.Subscribed)
|
not SuiteLinkSessionState.Subscribed)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Client is not ready for tag operations.");
|
throw new InvalidOperationException("Client is not ready for tag operations.");
|
||||||
@@ -508,4 +1215,8 @@ public sealed class SuiteLinkClient : IAsyncDisposable
|
|||||||
private readonly record struct FrameReadResult(
|
private readonly record struct FrameReadResult(
|
||||||
byte[] FrameBytes,
|
byte[] FrameBytes,
|
||||||
IReadOnlyList<DecodedUpdate> DeferredUpdates);
|
IReadOnlyList<DecodedUpdate> DeferredUpdates);
|
||||||
|
|
||||||
|
private readonly record struct PendingSyntheticDispatch(
|
||||||
|
Action<SuiteLinkTagUpdate> OnUpdate,
|
||||||
|
SuiteLinkTagUpdate Update);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ public sealed record class SuiteLinkConnectionOptions
|
|||||||
string userName,
|
string userName,
|
||||||
string serverNode,
|
string serverNode,
|
||||||
string? timezone = null,
|
string? timezone = null,
|
||||||
int port = 5413)
|
int port = 5413,
|
||||||
|
SuiteLinkRuntimeOptions? runtime = null)
|
||||||
{
|
{
|
||||||
ValidateRequired(host, nameof(host));
|
ValidateRequired(host, nameof(host));
|
||||||
ValidateRequired(application, nameof(application));
|
ValidateRequired(application, nameof(application));
|
||||||
@@ -35,6 +36,7 @@ public sealed record class SuiteLinkConnectionOptions
|
|||||||
ServerNode = serverNode;
|
ServerNode = serverNode;
|
||||||
Timezone = string.IsNullOrWhiteSpace(timezone) ? "UTC" : timezone;
|
Timezone = string.IsNullOrWhiteSpace(timezone) ? "UTC" : timezone;
|
||||||
Port = port;
|
Port = port;
|
||||||
|
Runtime = runtime ?? SuiteLinkRuntimeOptions.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Host { get; }
|
public string Host { get; }
|
||||||
@@ -46,6 +48,7 @@ public sealed record class SuiteLinkConnectionOptions
|
|||||||
public string ServerNode { get; }
|
public string ServerNode { get; }
|
||||||
public string Timezone { get; }
|
public string Timezone { get; }
|
||||||
public int Port { get; }
|
public int Port { get; }
|
||||||
|
public SuiteLinkRuntimeOptions Runtime { get; }
|
||||||
|
|
||||||
private static void ValidateRequired(string value, string paramName)
|
private static void ValidateRequired(string value, string paramName)
|
||||||
{
|
{
|
||||||
|
|||||||
39
src/SuiteLink.Client/SuiteLinkRetryPolicy.cs
Normal file
39
src/SuiteLink.Client/SuiteLinkRetryPolicy.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
namespace SuiteLink.Client;
|
||||||
|
|
||||||
|
public sealed record class SuiteLinkRetryPolicy
|
||||||
|
{
|
||||||
|
public SuiteLinkRetryPolicy(
|
||||||
|
TimeSpan initialDelay,
|
||||||
|
double multiplier,
|
||||||
|
TimeSpan maxDelay,
|
||||||
|
bool useJitter = true)
|
||||||
|
{
|
||||||
|
if (initialDelay < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(initialDelay), initialDelay, "Initial delay cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (multiplier <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(multiplier), multiplier, "Multiplier must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxDelay < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxDelay), maxDelay, "Max delay cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
InitialDelay = initialDelay;
|
||||||
|
Multiplier = multiplier;
|
||||||
|
MaxDelay = maxDelay;
|
||||||
|
UseJitter = useJitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSpan InitialDelay { get; init; }
|
||||||
|
public double Multiplier { get; init; }
|
||||||
|
public TimeSpan MaxDelay { get; init; }
|
||||||
|
public bool UseJitter { get; init; }
|
||||||
|
|
||||||
|
public static SuiteLinkRetryPolicy Default { get; } =
|
||||||
|
new(TimeSpan.FromSeconds(1), 2.0, TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
28
src/SuiteLink.Client/SuiteLinkRuntimeOptions.cs
Normal file
28
src/SuiteLink.Client/SuiteLinkRuntimeOptions.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
namespace SuiteLink.Client;
|
||||||
|
|
||||||
|
public sealed record class SuiteLinkRuntimeOptions
|
||||||
|
{
|
||||||
|
public SuiteLinkRuntimeOptions(
|
||||||
|
SuiteLinkRetryPolicy retryPolicy,
|
||||||
|
SuiteLinkCatchUpPolicy catchUpPolicy,
|
||||||
|
TimeSpan catchUpTimeout)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(retryPolicy);
|
||||||
|
|
||||||
|
if (catchUpTimeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(catchUpTimeout), catchUpTimeout, "Catch-up timeout must be positive.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RetryPolicy = retryPolicy;
|
||||||
|
CatchUpPolicy = catchUpPolicy;
|
||||||
|
CatchUpTimeout = catchUpTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SuiteLinkRetryPolicy RetryPolicy { get; init; }
|
||||||
|
public SuiteLinkCatchUpPolicy CatchUpPolicy { get; init; }
|
||||||
|
public TimeSpan CatchUpTimeout { get; init; }
|
||||||
|
|
||||||
|
public static SuiteLinkRuntimeOptions Default { get; } =
|
||||||
|
new(SuiteLinkRetryPolicy.Default, SuiteLinkCatchUpPolicy.None, TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
@@ -6,4 +6,5 @@ public sealed record class SuiteLinkTagUpdate(
|
|||||||
SuiteLinkValue Value,
|
SuiteLinkValue Value,
|
||||||
ushort Quality,
|
ushort Quality,
|
||||||
ushort ElapsedMilliseconds,
|
ushort ElapsedMilliseconds,
|
||||||
DateTimeOffset ReceivedAtUtc);
|
DateTimeOffset ReceivedAtUtc,
|
||||||
|
SuiteLinkUpdateSource Source = SuiteLinkUpdateSource.Live);
|
||||||
|
|||||||
7
src/SuiteLink.Client/SuiteLinkUpdateSource.cs
Normal file
7
src/SuiteLink.Client/SuiteLinkUpdateSource.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SuiteLink.Client;
|
||||||
|
|
||||||
|
public enum SuiteLinkUpdateSource
|
||||||
|
{
|
||||||
|
Live = 0,
|
||||||
|
CatchUpReplay = 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SuiteLink.Client.Transport;
|
||||||
|
|
||||||
|
public interface ISuiteLinkReconnectableTransport
|
||||||
|
{
|
||||||
|
ValueTask ResetConnectionAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ using System.Net.Sockets;
|
|||||||
|
|
||||||
namespace SuiteLink.Client.Transport;
|
namespace SuiteLink.Client.Transport;
|
||||||
|
|
||||||
public sealed class SuiteLinkTcpTransport : ISuiteLinkTransport
|
public sealed class SuiteLinkTcpTransport : ISuiteLinkTransport, ISuiteLinkReconnectableTransport
|
||||||
{
|
{
|
||||||
private readonly bool _leaveOpen;
|
private readonly bool _leaveOpen;
|
||||||
private readonly object _syncRoot = new();
|
private readonly object _syncRoot = new();
|
||||||
@@ -132,8 +132,8 @@ public sealed class SuiteLinkTcpTransport : ISuiteLinkTransport
|
|||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
Stream? streamToDispose = null;
|
Stream? streamToDispose;
|
||||||
TcpClient? tcpClientToDispose = null;
|
TcpClient? tcpClientToDispose;
|
||||||
|
|
||||||
lock (_syncRoot)
|
lock (_syncRoot)
|
||||||
{
|
{
|
||||||
@@ -143,30 +143,25 @@ public sealed class SuiteLinkTcpTransport : ISuiteLinkTransport
|
|||||||
}
|
}
|
||||||
|
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
|
||||||
if (!_leaveOpen)
|
|
||||||
{
|
|
||||||
streamToDispose = _stream;
|
|
||||||
tcpClientToDispose = _tcpClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
_stream = null;
|
|
||||||
_tcpClient = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tcpClientToDispose is not null)
|
(streamToDispose, tcpClientToDispose) = DetachConnection(disposeResources: !_leaveOpen);
|
||||||
|
await DisposeDetachedConnectionAsync(streamToDispose, tcpClientToDispose).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask ResetConnectionAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
|
Stream? streamToDispose;
|
||||||
|
TcpClient? tcpClientToDispose;
|
||||||
|
lock (_syncRoot)
|
||||||
{
|
{
|
||||||
tcpClientToDispose.Dispose();
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamToDispose is IAsyncDisposable asyncDisposable)
|
(streamToDispose, tcpClientToDispose) = DetachConnection(disposeResources: !_leaveOpen);
|
||||||
{
|
await DisposeDetachedConnectionAsync(streamToDispose, tcpClientToDispose).ConfigureAwait(false);
|
||||||
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
streamToDispose?.Dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Stream GetConnectedStream()
|
private Stream GetConnectedStream()
|
||||||
@@ -183,4 +178,35 @@ public sealed class SuiteLinkTcpTransport : ISuiteLinkTransport
|
|||||||
return _stream;
|
return _stream;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private (Stream? Stream, TcpClient? TcpClient) DetachConnection(bool disposeResources)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
var stream = disposeResources ? _stream : null;
|
||||||
|
var tcpClient = disposeResources ? _tcpClient : null;
|
||||||
|
|
||||||
|
_stream = null;
|
||||||
|
_tcpClient = null;
|
||||||
|
|
||||||
|
return (stream, tcpClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async ValueTask DisposeDetachedConnectionAsync(Stream? streamToDispose, TcpClient? tcpClientToDispose)
|
||||||
|
{
|
||||||
|
if (tcpClientToDispose is not null)
|
||||||
|
{
|
||||||
|
tcpClientToDispose.Dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamToDispose is IAsyncDisposable asyncDisposable)
|
||||||
|
{
|
||||||
|
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamToDispose?.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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);
|
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]
|
[Fact]
|
||||||
public void SetState_InvalidTransition_ThrowsInvalidOperationException()
|
public void SetState_InvalidTransition_ThrowsInvalidOperationException()
|
||||||
{
|
{
|
||||||
var session = new SuiteLinkSession();
|
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.Contains("Invalid state transition", ex.Message);
|
||||||
Assert.Equal(SuiteLinkSessionState.Disconnected, session.State);
|
Assert.Equal(SuiteLinkSessionState.Disconnected, session.State);
|
||||||
@@ -191,4 +235,17 @@ public sealed class SuiteLinkSessionTests
|
|||||||
Assert.False(session.TryTransitionState(SuiteLinkSessionState.Disconnected, SuiteLinkSessionState.HandshakeComplete));
|
Assert.False(session.TryTransitionState(SuiteLinkSessionState.Disconnected, SuiteLinkSessionState.HandshakeComplete));
|
||||||
Assert.Equal(SuiteLinkSessionState.TcpConnected, session.State);
|
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
|
public sealed class SuiteLinkClientConnectionTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[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 handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
|
||||||
var transport = new FakeTransport([handshakeAckFrame[..4], handshakeAckFrame[4..]]);
|
var transport = new FakeTransport([handshakeAckFrame[..4], handshakeAckFrame[4..]]);
|
||||||
@@ -15,7 +15,7 @@ public sealed class SuiteLinkClientConnectionTests
|
|||||||
|
|
||||||
await client.ConnectAsync(options);
|
await client.ConnectAsync(options);
|
||||||
|
|
||||||
Assert.False(client.IsConnected);
|
Assert.True(client.IsConnected);
|
||||||
Assert.Equal(2, transport.SentBuffers.Count);
|
Assert.Equal(2, transport.SentBuffers.Count);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake(
|
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.Protocol;
|
||||||
|
using SuiteLink.Client.Internal;
|
||||||
using SuiteLink.Client.Transport;
|
using SuiteLink.Client.Transport;
|
||||||
|
|
||||||
namespace SuiteLink.Client.Tests;
|
namespace SuiteLink.Client.Tests;
|
||||||
@@ -42,6 +43,73 @@ public sealed class SuiteLinkClientWriteTests
|
|||||||
Assert.Equal(2, transport.SentBuffers.Count);
|
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()
|
private static SuiteLinkConnectionOptions CreateOptions()
|
||||||
{
|
{
|
||||||
return new SuiteLinkConnectionOptions(
|
return new SuiteLinkConnectionOptions(
|
||||||
@@ -123,4 +191,54 @@ public sealed class SuiteLinkClientWriteTests
|
|||||||
return ValueTask.CompletedTask;
|
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);
|
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(
|
private static SuiteLinkConnectionOptions Create(
|
||||||
string host = "127.0.0.1",
|
string host = "127.0.0.1",
|
||||||
string application = "TestApp",
|
string application = "TestApp",
|
||||||
@@ -107,7 +138,8 @@ public sealed class SuiteLinkConnectionOptionsTests
|
|||||||
string userName = "User",
|
string userName = "User",
|
||||||
string serverNode = "Server",
|
string serverNode = "Server",
|
||||||
string? timezone = null,
|
string? timezone = null,
|
||||||
int port = 5413)
|
int port = 5413,
|
||||||
|
SuiteLinkRuntimeOptions? runtime = null)
|
||||||
{
|
{
|
||||||
return new SuiteLinkConnectionOptions(
|
return new SuiteLinkConnectionOptions(
|
||||||
host,
|
host,
|
||||||
@@ -118,6 +150,7 @@ public sealed class SuiteLinkConnectionOptionsTests
|
|||||||
userName,
|
userName,
|
||||||
serverNode,
|
serverNode,
|
||||||
timezone,
|
timezone,
|
||||||
port);
|
port,
|
||||||
|
runtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,38 @@ public sealed class SuiteLinkTcpTransportTests
|
|||||||
async () => await listener.AcceptTcpClientAsync(secondAcceptCts.Token));
|
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 sealed class PartialReadStream : Stream
|
||||||
{
|
{
|
||||||
private readonly MemoryStream _inner;
|
private readonly MemoryStream _inner;
|
||||||
|
|||||||
Reference in New Issue
Block a user