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
@@ -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