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();
}
}
@@ -328,6 +328,75 @@ public sealed class SqlBrowseSessionTests
() => session.ExpandAsync(SchemaNodeId, CancellationToken.None));
}
/// <summary>
/// A dispose racing an in-flight call must not <b>strand</b> the caller queued behind it. The registry's
/// TTL reaper disposes on its own thread while a picker may have two clicks outstanding, so this is the
/// ordinary shape, not a corner: the second call is parked in <c>SemaphoreSlim.WaitAsync</c>.
/// <para>Disposing the gate does not complete its pending async waiters, and the in-flight call's
/// <c>Release()</c> then throws <see cref="ObjectDisposedException"/> from inside the semaphore — so
/// the waiter is never released and the call never returns at all. The session must instead let it
/// through to the guarded <c>ObjectDisposedException.ThrowIf(_disposed, this)</c> every other member
/// raises.</para>
/// </summary>
[Fact]
public async Task DisposeAsync_doesNotStrandACallerQueuedOnTheGate()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var probe = new ConcurrencyProbe { Delay = TimeSpan.FromMilliseconds(400) };
var probing = new ConcurrencyProbingDbConnection(db.OpenNewConnection(), probe);
var session = new SqlBrowseSession(probing, new SqliteDialect());
// First call takes the gate and holds it for the probe's delay; second parks in WaitAsync.
var inFlight = session.RootAsync(CancellationToken.None);
await Task.Delay(100, TestContext.Current.CancellationToken);
var queued = session.ExpandAsync(SchemaNodeId, CancellationToken.None);
await session.DisposeAsync();
// The in-flight call's own outcome is not the assertion — it may fail on the closed connection.
try { await inFlight; }
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException) { }
// A stranded caller surfaces as TimeoutException here, which reads as "expected
// ObjectDisposedException, got TimeoutException" — the honest description of the defect.
var disposed = await Should.ThrowAsync<ObjectDisposedException>(
() => queued.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken));
disposed.ObjectName.ShouldBe(
typeof(SqlBrowseSession).FullName,
"the failure must be the session's own guarded throw, not the semaphore's");
}
/// <summary>
/// <see cref="IBrowseSession.LastUsedUtc"/> must advance when a call <b>starts</b>, not only when it
/// finishes. The AdminUI allows a catalog call 20 s; stamping only on completion leaves a slow expand
/// invisible to the registry's TTL reaper for its whole duration, so a session that idled up to the
/// edge of the TTL and was then given one long expand can be disposed mid-call — tearing the
/// connection down under an open reader.
/// </summary>
[Fact]
public async Task LastUsedUtc_advancesWhenACallSTARTS_notOnlyWhenItCompletes()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var probe = new ConcurrencyProbe { Delay = TimeSpan.FromSeconds(2) };
var probing = new ConcurrencyProbingDbConnection(db.OpenNewConnection(), probe);
await using var session = new SqlBrowseSession(probing, new SqliteDialect());
var atConstruction = session.LastUsedUtc;
// DateTime.UtcNow ticks at ~15.6 ms on Windows; without this the entry stamp could equal the
// construction stamp and the assertion would be about clock resolution rather than the stamp.
await Task.Delay(50, TestContext.Current.CancellationToken);
var call = session.RootAsync(CancellationToken.None);
// Observed WHILE the call is still running: the completion stamp cannot account for it.
await Task.Delay(300, TestContext.Current.CancellationToken);
call.IsCompleted.ShouldBeFalse("the probe's delay must still be holding the call open");
session.LastUsedUtc.ShouldBeGreaterThan(
atConstruction, "the stamp must advance on entry, not only on completion");
await call;
}
/// <summary>
/// The AdminUI bounds every call with a 20 s linked CTS
/// (<c>BrowserSessionService.PerCallTimeout</c>) — the session invents no timeout of its own, it just