fix(focas): recycle the device handle under ConnectGate and drain it first (R07:L6)

The handle-recycle loop disposed the device client with no gate. It could null a client a
concurrent EnsureConnectedAsync had just installed (orphaning a live two-socket session),
or dispose one a poll was mid-read on (ObjectDisposedException -> BadCommunicationError +
Degraded on every recycle interval).

The recycle now takes the device's ConnectGate, detaches the client, and disposes it via
SynchronizedFocasClient.DisposeWhenIdleAsync, which waits (bounded by the call timeout plus
slack) for ops that already hold the client. The gate stays held through the drain, so no
fresh session opens beside the old one.

SynchronizedFocasClient no longer disposes its call gate. Releasing a disposed semaphore
threw out of reads that had otherwise completed. Ops that reach a disposed client now get
ObjectDisposedException and are never run on the dead socket.

Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:21:02 -04:00
parent 256915b7e4
commit 4c4dfaadcc
3 changed files with 158 additions and 18 deletions
@@ -32,6 +32,9 @@ public sealed class SynchronizedFocasClient : IFocasClient
private readonly IFocasClient _inner;
private readonly TimeSpan _callTimeout;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>Ops holding or queued for <see cref="_gate"/> — what <see cref="DisposeWhenIdleAsync"/> drains.</summary>
private int _pending;
private int _disposed;
/// <summary>Wraps <paramref name="inner"/> with per-device serialization + a per-call timeout.</summary>
/// <param name="inner">The underlying FOCAS client to serialize access to.</param>
@@ -115,26 +118,66 @@ public sealed class SynchronizedFocasClient : IFocasClient
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken);
/// <summary>Disposes the inner client and the call gate.</summary>
/// <summary>
/// Disposes the inner client. Ops still queued on the call gate then fail with
/// <see cref="ObjectDisposedException"/> instead of running on a disposed socket. The gate itself is NOT
/// disposed: an op that already holds it releases it on the way out, and releasing a disposed
/// <see cref="SemaphoreSlim"/> threw out of a read that had otherwise completed.
/// </summary>
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_inner.Dispose();
_gate.Dispose();
}
/// <summary>
/// Disposes once every op that already holds (or is queued for) this client has finished, waiting at most
/// <paramref name="maxWait"/>. Used by the handle-recycle loop, which detaches the client under the device's
/// connect gate first — so nothing new can reach it — and then must not kill a read that is mid-flight on
/// it (that read used to surface BadCommunicationError + Degraded on every recycle tick).
/// </summary>
/// <param name="maxWait">Upper bound on the wait; the client is disposed regardless once it passes.</param>
/// <returns>A task that completes when the client is disposed.</returns>
public async Task DisposeWhenIdleAsync(TimeSpan maxWait)
{
var deadline = DateTime.UtcNow + maxWait;
while (Volatile.Read(ref _pending) > 0 && DateTime.UtcNow < deadline)
await Task.Delay(5, CancellationToken.None).ConfigureAwait(false);
Dispose();
}
/// <summary>Gets the number of ops currently holding or queued for the call gate. Test seam.</summary>
internal int PendingOperations => Volatile.Read(ref _pending);
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(SynchronizedFocasClient), "The FOCAS client was recycled or disposed.");
}
// Gate only — the caller already governs the budget (connect timeout arg / probe linked token).
private async Task<T> RunGatedAsync<T>(Func<CancellationToken, Task<T>> op, CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try { return await op(ct).ConfigureAwait(false); }
finally { _gate.Release(); }
Interlocked.Increment(ref _pending);
try
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try { ThrowIfDisposed(); return await op(ct).ConfigureAwait(false); }
finally { _gate.Release(); }
}
finally { Interlocked.Decrement(ref _pending); }
}
private async Task RunGatedAsync(Func<CancellationToken, Task> op, CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try { await op(ct).ConfigureAwait(false); }
finally { _gate.Release(); }
Interlocked.Increment(ref _pending);
try
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try { ThrowIfDisposed(); await op(ct).ConfigureAwait(false); }
finally { _gate.Release(); }
}
finally { Interlocked.Decrement(ref _pending); }
}
// Gate + per-call timeout. A fired timeout surfaces as OperationCanceledException whose token is
@@ -142,16 +185,22 @@ public sealed class SynchronizedFocasClient : IFocasClient
// their own token's IsCancellationRequested.
private async Task<T> RunBoundedAsync<T>(Func<CancellationToken, Task<T>> op, CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
Interlocked.Increment(ref _pending);
try
{
if (_callTimeout <= TimeSpan.Zero)
return await op(ct).ConfigureAwait(false);
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
ThrowIfDisposed();
if (_callTimeout <= TimeSpan.Zero)
return await op(ct).ConfigureAwait(false);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(_callTimeout);
return await op(linked.Token).ConfigureAwait(false);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(_callTimeout);
return await op(linked.Token).ConfigureAwait(false);
}
finally { _gate.Release(); }
}
finally { _gate.Release(); }
finally { Interlocked.Decrement(ref _pending); }
}
}