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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user