fix(client): a failover abandons itself once the user disconnects or reconnects; paging and teardown minors

Review I1: ConnectAsync, DisconnectAsync and Dispose each start a new connection
epoch under _subscriptionLock. A keep-alive failover runs under the epoch it
started in and abandons itself the moment it moves: it installs its session only
if the epoch still matches (checked and assigned under the lock, otherwise the
session is disposed), its state transitions are epoch-guarded, it stops trying
endpoints, and it never flips a user's new connection to Disconnected. A late
keep-alive from a previous connection's session starts no failover. Gate-driven
tests cover the success and all-fail arms, the stale keep-alive, with a genuine
failover as the positive control (4 of them red before).

Minors: m8 only the CompareExchange winner sets LastFailover; m5 the endpoint
loop stops early, and a failed Unsubscribe still drops its bookkeeping; m1 a read
cancelled mid-paging releases its cursor (bounded token) and rethrows, and the cap
release no longer rides the caller's token; m2 results are trimmed to the cap;
m3 a non-positive cap is refused (CLI and UI already bound it to >= 1); m6 the
subscription adapter's Dispose uses one bounded bridge for delete + detach.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:18:26 -04:00
parent 31605e547d
commit 0c9e4f4e3d
7 changed files with 442 additions and 74 deletions
@@ -145,17 +145,16 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
/// </summary>
public void Dispose()
{
// One bounded bridge (5 s) for delete + detach, so a dispose never blocks longer than before (review m6).
// DetachAsync swallows its own failures; a failed delete must not skip the detach.
try
{
DisposeBridge.Run(ct => _subscription.DeleteAsync(true, ct));
}
catch
{
}
try
{
DisposeBridge.Run(DetachAsync);
DisposeBridge.Run(async ct =>
{
try { await _subscription.DeleteAsync(true, ct); }
catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
await DetachAsync(ct);
});
}
catch
{