fix(sql-browser): close two races around the registry reaper (Driver.Sql.Browser-002)

(1) DisposeAsync disposed _gate unconditionally. SemaphoreSlim.Dispose does not
complete pending async waiters, and the in-flight call's Release() then threw
ObjectDisposedException from inside the semaphore (swallowed by the finally), so
a caller parked in WaitAsync was never released and its call never returned. The
gate is now left alive - the pattern SqlPollReader documents - so the waiter is
admitted and meets the session's own guarded ThrowIf(_disposed, this).

(2) LastUsedUtc advanced only on completion, so a slow catalog call (the AdminUI
allows 20 s) was invisible to the TTL reaper for its whole duration and a session
at the edge of its TTL could be disposed mid-call. It is now stamped on entry
too.
This commit is contained in:
Joseph Doherty
2026-08-10 08:01:30 -04:00
parent a8cfc886a3
commit cd4b29a368
2 changed files with 91 additions and 11 deletions
@@ -205,8 +205,18 @@ internal sealed class SqlBrowseSession : IBrowseSession
}
/// <summary>
/// Idempotently closes the owned connection and the gate. Errors are swallowed: the registry's reaper
/// may be racing a server-side disconnect, and a failed close must never surface in the AdminUI.
/// Idempotently closes the owned connection. Errors are swallowed: the registry's reaper may be racing
/// a server-side disconnect, and a failed close must never surface in the AdminUI.
/// <para><b>The gate is deliberately NOT disposed</b> — the same call
/// <c>SqlPollReader</c> makes, and for a sharper reason here. This session is disposed by the
/// registry's TTL reaper, on its own thread, while a picker may have a second call parked in
/// <see cref="SemaphoreSlim.WaitAsync(CancellationToken)"/>. Disposing the semaphore does not complete
/// its pending async waiters, and the in-flight call's <c>Release()</c> would then throw
/// <see cref="ObjectDisposedException"/> from inside the semaphore — so the parked caller is never
/// released and its call never returns at all. Left undisposed, the waiter is admitted and meets the
/// guarded <c>ObjectDisposedException.ThrowIf(_disposed, this)</c> every other member raises.
/// A <see cref="SemaphoreSlim"/> that was never handed out as a <see cref="WaitHandle"/> holds no
/// unmanaged resource, so not disposing it leaks nothing.</para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
@@ -216,9 +226,6 @@ internal sealed class SqlBrowseSession : IBrowseSession
try { await _connection.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort: the connection may already be broken or closed. */ }
try { _gate.Dispose(); }
catch { /* best-effort: a concurrent second dispose already tore it down. */ }
}
private async Task<IReadOnlyList<BrowseNode>> ExpandSchemaAsync(string schema, CancellationToken ct)
@@ -367,8 +374,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// <summary>
/// Runs one catalog query under the gate and projects every row. The disposed check happens
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed, and
/// <see cref="LastUsedUtc"/> only advances on a call that actually completed.
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed.
/// <para><see cref="LastUsedUtc"/> is stamped <b>on entry as well as on completion</b>. The AdminUI
/// allows a catalog call 20 s (<c>BrowserSessionService.PerCallTimeout</c>) and a queued one waits
/// behind it; stamping only on completion leaves a long expand invisible to the registry's TTL reaper
/// for its whole duration, so a session that idled to the edge of the TTL and was then given one slow
/// call gets reaped mid-call — tearing the connection down under an open reader.</para>
/// </summary>
private async Task<List<T>> QueryAsync<T>(
string sql,
@@ -376,6 +387,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
Func<DbDataReader, T> project,
CancellationToken cancellationToken)
{
LastUsedUtc = DateTime.UtcNow;
ObjectDisposedException.ThrowIf(_disposed, this);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
@@ -400,10 +412,9 @@ internal sealed class SqlBrowseSession : IBrowseSession
}
finally
{
// A dispose that raced this call has already disposed the gate; releasing it then is harmless to
// ignore and must not mask the real result.
try { _gate.Release(); }
catch (ObjectDisposedException) { }
// Unconditional, and it cannot throw: DisposeAsync deliberately leaves the gate alive precisely
// so this release still reaches whoever is parked behind us.
_gate.Release();
}
}