Files
suitelinkclient/docs/plans/2026-03-17-runtime-reconnect-implementation-plan.md
2026-03-17 11:04:19 -04:00

520 lines
18 KiB
Markdown

# 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"
```