diff --git a/.editorconfig b/.editorconfig
index 819a41fe1e..4590621c96 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -198,3 +198,9 @@ dotnet_diagnostic.CA1416.severity = silent
dotnet_code_quality.CA2100.excluded_type_names_with_derived_types = Microsoft.Data.SqlClient.ManualTesting.Tests.*
dotnet_diagnostic.xUnit1031.severity=none
dotnet_diagnostic.xUnit1030.severity=none
+
+# Disables warning for unnamed enum case values
+# e.g. providing an invalid int value for an enum that wraps int
+dotnet_diagnostic.CS8524.severity = none
+# Treat missing enum cases as errors
+dotnet_diagnostic.CS8509.severity = error
\ No newline at end of file
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs
index 568592072d..bee990bdc1 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs
@@ -149,9 +149,10 @@ internal static Exception ExceptionWithStackTrace(Exception e)
}
}
+#nullable enable
internal static Timer UnsafeCreateTimer(
TimerCallback callback,
- object state,
+ object? state,
int dueTimeMilliseconds,
int periodMilliseconds) =>
UnsafeCreateTimer(
@@ -160,7 +161,7 @@ internal static Timer UnsafeCreateTimer(
TimeSpan.FromMilliseconds(dueTimeMilliseconds),
TimeSpan.FromMilliseconds(periodMilliseconds));
- internal static Timer UnsafeCreateTimer(TimerCallback callback, object state, TimeSpan dueTime, TimeSpan period)
+ internal static Timer UnsafeCreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
// Don't capture the current ExecutionContext and its AsyncLocals onto
// a global timer causing them to live forever
@@ -175,6 +176,39 @@ internal static Timer UnsafeCreateTimer(TimerCallback callback, object state, Ti
}
}
+ ///
+ /// Creates an using the supplied without
+ /// capturing the current . This overload accepts a
+ /// parameterless for callbacks that do not require state.
+ ///
+ /// The time provider used to create the timer.
+ /// The parameterless delegate invoked when the timer fires.
+ ///
+ /// The amount of time to wait before the first invocation, or
+ /// to create the timer disarmed.
+ /// The interval between invocations, or
+ /// to disable periodic signaling.
+ /// An created by .
+ internal static ITimer UnsafeCreateTimer(
+ TimeProvider timeProvider,
+ Action callback,
+ T state,
+ TimeSpan dueTime,
+ TimeSpan period)
+ {
+ if (ExecutionContext.IsFlowSuppressed())
+ {
+ return timeProvider.CreateTimer(s => callback((T)s!), state, dueTime, period);
+ }
+
+ using (ExecutionContext.SuppressFlow())
+ {
+ return timeProvider.CreateTimer(s => callback((T)s!), state, dueTime, period);
+ }
+ }
+
+
+#nullable restore
#region COM+ exceptions
internal static ArgumentException Argument(string error)
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/BlockingPeriodErrorState.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/BlockingPeriodErrorState.cs
new file mode 100644
index 0000000000..47d0156a7e
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/BlockingPeriodErrorState.cs
@@ -0,0 +1,250 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Threading;
+using Microsoft.Data.Common;
+using Microsoft.Data.SqlClient.Internal;
+
+#nullable enable
+
+namespace Microsoft.Data.SqlClient.ConnectionPool
+{
+ ///
+ /// Encapsulates a connection pool's blocking-period error state: cached exception, exponential
+ /// backoff timer, and synchronization. Kept as a separate class so the pool's
+ /// connection-acquisition path remains focused on capacity/queue concerns and stays
+ /// decoupled from the (independent) rate limiting policy.
+ ///
+ internal sealed class BlockingPeriodErrorState : IDisposable
+ {
+ // Backoff interval used the first time the pool enters the blocking period, and the
+ // value the backoff resets to on a successful create or Clear().
+ private static readonly TimeSpan InitialWait = TimeSpan.FromSeconds(5);
+
+ // Upper bound the exponential backoff is capped at; further failures never wait longer.
+ private static readonly TimeSpan MaxWait = TimeSpan.FromSeconds(60);
+
+ // Identifier of the owning pool, included in trace events for diagnostics.
+ private readonly int _ownerPoolId;
+
+ // Optional callback invoked (under _lock) when the state enters the blocking period;
+ // Must be cheap and non-reentrant. Must not throw. If null, no action is taken.
+ private readonly Action? _onEnter;
+
+ // Optional callback invoked (under _lock) when the state leaves the blocking period
+ // via the exit timer or Clear(). Must be cheap and non-reentrant. Must not throw.
+ // If null, no action is taken.
+ private readonly Action? _onExit;
+
+ // Time source used to create the exit timer; overridable so tests can drive scheduling
+ // deterministically. Defaults to TimeProvider.System.
+ private readonly TimeProvider _timeProvider;
+
+ // Guards the mutable error state (_cachedException, _exitTimer, _nextWait, _disposed).
+ private readonly object _lock = new();
+
+ // Non-null while the pool is in the blocking period. Doubles as the "has error"
+ // flag, so callers don't need a separate bool. Volatile so other threads observe
+ // entry/exit transitions without acquiring _lock.
+ private volatile Exception? _cachedException;
+
+ // True from the moment Enter() activates the blocking period until Clear()/Dispose()
+ // fully resets it. The exit timer clears _cachedException but leaves this set so a
+ // later Clear() still resets the backoff. Volatile so Clear() can take a
+ // lock-free path when there is nothing to do. This allows Clear() to be called on hot paths.
+ private volatile bool _inElevatedState;
+
+ // The armed exit timer that ends the current blocking period; null when no period is
+ // active. Replaced (and the old one disposed) each time Enter() is called.
+ private ITimer? _exitTimer;
+
+ // The backoff interval to use for the next Enter(); doubles per failure up to MaxWait
+ // and resets to InitialWait on a successful create or Clear().
+ private TimeSpan _nextWait = InitialWait;
+
+ // True once Dispose() has run, so repeated disposal and post-teardown work are no-ops.
+ private bool _disposed;
+
+ ///
+ /// Creates a new instance.
+ ///
+ /// Identifier of the owning pool, used in trace events.
+ /// Optional callback invoked (while holding the internal lock) after
+ /// the state transitions into the blocking period. Used by the legacy wait-handle pool to
+ /// signal its error wait handle. Because it runs under a lock, it must be cheap and
+ /// non-reentrant.
+ /// Optional callback invoked (while holding the internal lock) after the
+ /// state transitions out of the blocking period via the exit timer or .
+ /// Because it runs under a lock, it must be cheap and non-reentrant.
+ /// The time provider used to create the exit timer. Defaults to
+ /// . Inject a test double (e.g.
+ /// Microsoft.Extensions.Time.Testing.FakeTimeProvider) in unit tests to
+ /// control timer scheduling deterministically.
+ internal BlockingPeriodErrorState(int ownerPoolId, Action? onEnter = null, Action? onExit = null, TimeProvider? timeProvider = null)
+ {
+ _ownerPoolId = ownerPoolId;
+ _onEnter = onEnter;
+ _onExit = onExit;
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ ///
+ /// True while the pool is in the blocking period. Subsequent acquisition attempts
+ /// should fast-fail with the cached exception.
+ ///
+ internal bool HasError => _cachedException is not null;
+
+ ///
+ /// Throws the cached error if the pool is currently in the blocking period.
+ ///
+ internal void ThrowIfActive()
+ {
+ Exception? cached = _cachedException;
+ if (cached is null)
+ {
+ return;
+ }
+
+ // Clone SqlExceptions so stack traces are not shared across callers; other
+ // exception types are rethrown as-is.
+ throw cached is SqlException sqlEx ? sqlEx.InternalClone() : cached;
+ }
+
+ ///
+ /// Enters the blocking period, caching the supplied exception and scheduling a timer
+ /// to exit the period after the current backoff interval. Subsequent failures double
+ /// the backoff up to .
+ ///
+ internal void Enter(Exception ex)
+ {
+ TimeSpan wait;
+ ITimer? oldTimer;
+
+ // If we call this, we're already in an exception path. Prefer correctness over performance.
+ lock (_lock)
+ {
+ _inElevatedState = true;
+ _cachedException = ex;
+ wait = _nextWait;
+
+ ITimer newTimer = ADP.UnsafeCreateTimer(
+ _timeProvider,
+ ExitCallback,
+ this,
+ wait,
+ wait);
+ oldTimer = _exitTimer;
+ _exitTimer = newTimer;
+
+ // Bump the backoff for the next failure, capped at MaxWait. FR-008.
+ TimeSpan doubled = _nextWait + _nextWait;
+ _nextWait = doubled > MaxWait ? MaxWait : doubled;
+
+ // Signal the enter event while still holding the lock so the external signal order
+ // (onEnter/onExit) can never diverge from the internal state transitions under
+ // concurrent Enter/Clear/exit-timer activity. The callbacks are expected to be
+ // cheap, non-reentrant operations.
+ _onEnter?.Invoke();
+ }
+
+ oldTimer?.Dispose();
+
+ SqlClientEventSource.Log.TryPoolerTraceEvent(
+ " {0}, Entering blocking period for {1}ms.",
+ _ownerPoolId,
+ (int)wait.TotalMilliseconds);
+ }
+
+ ///
+ /// Clears the cached error state, disposes the exit timer, and resets the backoff to
+ /// its initial value.
+ ///
+ internal void Clear()
+ {
+ // Fast path: the create flow calls Clear() after every successful create, so avoid
+ // taking the lock in the common (no-error) case where there is nothing to reset.
+ if (!_inElevatedState)
+ {
+ return;
+ }
+
+ ITimer? oldTimer;
+
+ lock (_lock)
+ {
+ _cachedException = null;
+ _nextWait = InitialWait;
+ _inElevatedState = false;
+ oldTimer = _exitTimer;
+ _exitTimer = null;
+
+ // Signal and trace under the lock so the exit signal is ordered consistently
+ // with the state transition relative to concurrent Enter/exit-timer callbacks.
+ _onExit?.Invoke();
+ }
+
+ oldTimer?.Dispose();
+
+ SqlClientEventSource.Log.TryPoolerTraceEvent(
+ " {0}, Error state cleared.", _ownerPoolId);
+ }
+
+ ///
+ /// Timer callback that exits the blocking period by clearing the cached exception,
+ /// allowing the next caller to attempt a fresh connection creation. The current
+ /// backoff is left intact so that, if the next attempt fails, the backoff continues
+ /// to grow rather than resetting. The backoff is reset only on .
+ ///
+ private static void ExitCallback(BlockingPeriodErrorState state)
+ {
+ ITimer? oldTimer;
+
+ lock (state._lock)
+ {
+ state._cachedException = null;
+ oldTimer = state._exitTimer;
+ state._exitTimer = null;
+
+ // Signal under the lock so the exit signal is ordered consistently
+ // with the state transition relative to concurrent Enter/Clear callbacks.
+ try {
+ state._onExit?.Invoke();
+ } catch {
+ // Ignore exceptions from the exit event.
+ }
+ }
+
+ oldTimer?.Dispose();
+
+ SqlClientEventSource.Log.TryPoolerTraceEvent(
+ " {0}, Exiting blocking period.", state._ownerPoolId);
+ }
+
+ ///
+ /// Disposes the instance, releasing the exit timer if one is active. Clears the
+ /// error state so that any waiting callers do not observe a stale exception after
+ /// the owning pool is torn down.
+ ///
+ public void Dispose()
+ {
+ ITimer? timerToDispose;
+ lock (_lock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _cachedException = null;
+ _inElevatedState = false;
+ timerToDispose = _exitTimer;
+ _exitTimer = null;
+ }
+
+ timerToDispose?.Dispose();
+ _disposed = true;
+ }
+ }
+}
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs
index 8f62787e30..085d300903 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs
@@ -93,6 +93,20 @@ internal DbConnectionPoolGroupProviderInfo ProviderInfo
internal SqlMetaDataFactory MetaDataFactory { get; set; }
+ ///
+ /// Determines whether the blocking period is enabled for this pool group based on the
+ /// configured and the target data source.
+ ///
+ internal bool IsBlockingPeriodEnabled()
+ {
+ return _connectionOptions.PoolBlockingPeriod switch
+ {
+ PoolBlockingPeriod.Auto => !ADP.IsAzureSqlServerEndpoint(_connectionOptions.DataSource),
+ PoolBlockingPeriod.AlwaysBlock => true,
+ PoolBlockingPeriod.NeverBlock => false
+ };
+ }
+
internal int Clear()
{
// must be multi-thread safe with competing calls by Clear and Prune via background thread
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs
index d2c3b57323..b214193fd9 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs
@@ -29,7 +29,7 @@ namespace Microsoft.Data.SqlClient.ConnectionPool
/// - Transaction-Aware Pooling: Tracks connections enlisted in using TransactedConnectionPool and TransactedConnectionList, ensuring proper context reuse.
/// - Concurrency and Synchronization: Uses wait handles and semaphores via PoolWaitHandles to coordinate safe multi-threaded access.
/// - Connection Lifecycle Management: Manages creation (CreateObject), deactivation (DeactivateObject), destruction (DestroyObject), and reclamation (ReclaimEmancipatedObjects) of internal connections.
- /// - Error Handling and Resilience: Implements retry and exponential backoff in TryGetConnection and handles transient errors using _errorWait.
+ /// - Error Handling and Resilience: Implements retry and exponential backoff in TryGetConnection and delegates blocking-period bookkeeping (cached exception, exit timer) to .
/// - Minimum Pool Size Enforcement: Maintains the MinPoolSize by spawning background tasks to create new connections when needed.
/// - Load Balancing Support: Honors LoadBalanceTimeout to clean up idle connections and distribute load evenly.
/// - Telemetry and Tracing: Uses SqlClientEventSource for extensive diagnostic tracing of connection lifecycle events.
@@ -165,8 +165,6 @@ public void Dispose()
private const int WAIT_ABANDONED = 0x80;
- private const int ERROR_WAIT_DEFAULT = 5 * 1000; // 5 seconds
-
// we do want a testable, repeatable set of generated random numbers
private static readonly Random s_random = new Random(5101977); // Value obtained from Dave Driver
@@ -194,11 +192,8 @@ public void Dispose()
private int _waitCount;
private readonly PoolWaitHandles _waitHandles;
- private Exception _resError;
- private volatile bool _errorOccurred;
-
- private int _errorWait;
- private Timer _errorTimer;
+ private readonly TimeProvider _timeProvider;
+ private readonly BlockingPeriodErrorState _errorState;
private Timer _cleanupTimer;
@@ -212,7 +207,8 @@ internal WaitHandleDbConnectionPool(
SqlConnectionFactory connectionFactory,
DbConnectionPoolGroup connectionPoolGroup,
DbConnectionPoolIdentity identity,
- DbConnectionPoolProviderInfo connectionPoolProviderInfo)
+ DbConnectionPoolProviderInfo connectionPoolProviderInfo,
+ TimeProvider timeProvider = null)
{
Debug.Assert(connectionPoolGroup != null, "null connectionPoolGroup");
@@ -249,11 +245,19 @@ internal WaitHandleDbConnectionPool(
_connectionPoolGroupOptions = connectionPoolGroup.PoolGroupOptions;
_connectionPoolProviderInfo = connectionPoolProviderInfo;
_identity = identity;
+ _timeProvider = timeProvider ?? TimeProvider.System;
_waitHandles = new PoolWaitHandles();
- _errorWait = ERROR_WAIT_DEFAULT;
- _errorTimer = null; // No error yet.
+ // Hook the wait-handle event so any thread blocked in WaitAny over the pool's
+ // handles wakes up immediately when the blocking period is entered/exited.
+ // _timeProvider is the system clock in production; tests inject a fake clock to
+ // drive the exit timer deterministically.
+ _errorState = new BlockingPeriodErrorState(
+ Id,
+ onEnter: () => _waitHandles.ErrorEvent.Set(),
+ onExit: () => _waitHandles.ErrorEvent.Reset(),
+ timeProvider: _timeProvider);
_objectList = new List(MaxPoolSize);
@@ -283,7 +287,7 @@ private int CreationTimeout
public SqlConnectionFactory ConnectionFactory => _connectionFactory;
- public bool ErrorOccurred => _errorOccurred;
+ public bool ErrorOccurred => _errorState.HasError;
private bool HasTransactionAffinity => PoolGroupOptions.HasTransactionAffinity;
@@ -515,39 +519,6 @@ private Timer CreateCleanupTimer() =>
_cleanupWait,
_cleanupWait);
- private bool IsBlockingPeriodEnabled()
- {
- var poolGroupConnectionOptions = _connectionPoolGroup.ConnectionOptions;
- if (poolGroupConnectionOptions == null)
- {
- return true;
- }
-
- var policy = poolGroupConnectionOptions.PoolBlockingPeriod;
-
- switch (policy)
- {
- case PoolBlockingPeriod.Auto:
- {
- return !ADP.IsAzureSqlServerEndpoint(poolGroupConnectionOptions.DataSource);
- }
- case PoolBlockingPeriod.AlwaysBlock:
- {
- return true; //Enabled
- }
- case PoolBlockingPeriod.NeverBlock:
- {
- return false; //Disabled
- }
- default:
- {
- //we should never get into this path.
- Debug.Fail("Unknown PoolBlockingPeriod. Please specify explicit results in above switch case statement.");
- return true;
- }
- }
- }
-
private DbConnectionInternal CreateObject(DbConnection owningObject, DbConnectionInternal oldConnection, TimeoutTimer timeout)
{
DbConnectionInternal newObj = null;
@@ -573,14 +544,14 @@ private DbConnectionInternal CreateObject(DbConnection owningObject, DbConnectio
SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, Added to pool.", Id, newObj?.ObjectID);
- // Reset the error wait:
- _errorWait = ERROR_WAIT_DEFAULT;
+ // A successful creation clears any prior error state and resets backoff.
+ _errorState.Clear();
}
catch (Exception e) when (ADP.IsCatchableExceptionType(e))
{
ADP.TraceExceptionWithoutRethrow(e);
- if (!IsBlockingPeriodEnabled())
+ if (!_connectionPoolGroup.IsBlockingPeriodEnabled())
{
throw;
}
@@ -593,33 +564,10 @@ private DbConnectionInternal CreateObject(DbConnection owningObject, DbConnectio
newObj = null; // set to null, so we do not return bad new object
- // Failed to create instance
- _resError = e;
-
- // Make sure the timer starts even if ThreadAbort occurs after setting the ErrorEvent.
- Timer t = new Timer(new TimerCallback(this.ErrorCallback), null, Timeout.Infinite, Timeout.Infinite);
-
- bool timerIsNotDisposed;
-
- _waitHandles.ErrorEvent.Set();
- _errorOccurred = true;
-
- // Enable the timer.
- // Note that the timer is created to allow periodic invocation. If ThreadAbort occurs in the middle of ErrorCallback,
- // the timer will restart. Otherwise, the timer callback (ErrorCallback) destroys the timer after resetting the error to avoid second callback.
- _errorTimer = t;
- timerIsNotDisposed = t.Change(_errorWait, _errorWait);
-
- Debug.Assert(timerIsNotDisposed, "ErrorCallback timer has been disposed");
+ // Enter the blocking period: caches the exception, schedules the exit timer,
+ // and signals the wait-handle error event via the onEnter callback.
+ _errorState.Enter(e);
- if (30000 < _errorWait)
- {
- _errorWait = 60000;
- }
- else
- {
- _errorWait *= 2;
- }
throw;
}
return newObj;
@@ -792,28 +740,6 @@ private void DestroyObject(DbConnectionInternal obj)
}
}
- private void ErrorCallback(object state)
- {
- SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Resetting Error handling.", Id);
- _errorOccurred = false;
- _waitHandles.ErrorEvent.Reset();
-
- // the error state is cleaned, destroy the timer to avoid periodic invocation
- Timer t = _errorTimer;
- _errorTimer = null;
- if (t != null)
- {
- t.Dispose(); // Cancel timer request.
- }
- }
-
-
- private Exception TryCloneCachedException()
- // Cached exception can be of any type, so is not always cloneable.
- // This functions clones SqlException
- // OleDb and Odbc connections are not passing throw this code
- => _resError is SqlException sqlEx ? sqlEx.InternalClone() : _resError;
-
private void WaitForPendingOpen()
{
Debug.Assert(!Thread.CurrentThread.IsThreadPoolThread, "This thread may block for a long time. Threadpool threads should not be used.");
@@ -1037,7 +963,12 @@ private bool TryGetConnection(DbConnection owningObject, uint waitForMultipleObj
// Throw the error that PoolCreateRequest stashed.
SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Errors are set.", Id);
Interlocked.Decrement(ref _waitCount);
- throw TryCloneCachedException();
+ _errorState.ThrowIfActive();
+ // Narrow race: error state cleared between WaitAny observing
+ // the signal and this check. Re-balance _waitCount and let the
+ // outer do/while loop retry.
+ Interlocked.Increment(ref _waitCount);
+ break;
case CREATION_HANDLE:
SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Creating new connection.", Id);
@@ -1616,25 +1547,20 @@ private DbConnectionInternal UserCreateRequest(DbConnection owningObject, Timeou
// instead obtained creation mutex
DbConnectionInternal obj = null;
- if (ErrorOccurred)
- {
- throw TryCloneCachedException();
- }
- else
+ _errorState.ThrowIfActive();
+
+ if ((oldConnection != null) || (Count < MaxPoolSize) || (0 == MaxPoolSize))
{
- if ((oldConnection != null) || (Count < MaxPoolSize) || (0 == MaxPoolSize))
- {
- // If we have an odd number of total objects, reclaim any dead objects.
- // If we did not find any objects to reclaim, create a new one.
+ // If we have an odd number of total objects, reclaim any dead objects.
+ // If we did not find any objects to reclaim, create a new one.
- // TODO: Consider implement a control knob here; why do we only check for dead objects ever other time? why not every 10th time or every time?
- if ((oldConnection != null) || (Count & 0x1) == 0x1 || !ReclaimEmancipatedObjects())
- {
- obj = CreateObject(owningObject, oldConnection, timeout);
- }
+ // TODO: Consider implement a control knob here; why do we only check for dead objects ever other time? why not every 10th time or every time?
+ if ((oldConnection != null) || (Count & 0x1) == 0x1 || !ReclaimEmancipatedObjects())
+ {
+ obj = CreateObject(owningObject, oldConnection, timeout);
}
- return obj;
}
+ return obj;
}
}
}
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/BlockingPeriodErrorStateTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/BlockingPeriodErrorStateTest.cs
new file mode 100644
index 0000000000..157249f5f9
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/BlockingPeriodErrorStateTest.cs
@@ -0,0 +1,688 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Threading;
+using Microsoft.Extensions.Time.Testing;
+using Microsoft.Data.SqlClient.ConnectionPool;
+using Xunit;
+
+#nullable enable
+
+namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool
+{
+ ///
+ /// Comprehensive unit tests for covering:
+ /// - Initial state and error caching
+ /// - and exception handling
+ /// - and state reset
+ /// - Exponential backoff progression (verified with )
+ /// - Timer-driven exit behavior (verified with )
+ /// - implementation and timer cleanup
+ /// - Callback invocation and re-entrancy safety
+ ///
+ public class BlockingPeriodErrorStateTest
+ {
+ #region HasError / initial state
+
+ ///
+ /// Verifies that a newly constructed has
+ /// set to false.
+ ///
+ [Fact]
+ public void HasError_InitialState_IsFalse()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+
+ // Act & Assert
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that does not throw
+ /// when called on a newly constructed instance with no cached error.
+ ///
+ [Fact]
+ public void ThrowIfActive_InitialState_DoesNotThrow()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+
+ // Act & Assert
+ state.ThrowIfActive(); // Should complete without throwing
+ }
+
+ #endregion
+
+ #region Enter
+
+ ///
+ /// Verifies that calling sets
+ /// to true.
+ ///
+ [Fact]
+ public void Enter_SetsHasErrorToTrue()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+
+ // Act
+ state.Enter(new InvalidOperationException("test"));
+
+ // Assert
+ Assert.True(state.HasError);
+ }
+
+ ///
+ /// Verifies that throws
+ /// the exact exception type that was cached by .
+ ///
+ [Fact]
+ public void Enter_ThrowIfActive_ThrowsCachedExceptionType()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ var exception = new InvalidOperationException("boom");
+
+ // Act
+ state.Enter(exception);
+
+ // Assert
+ var ex = Assert.Throws(() => state.ThrowIfActive());
+ Assert.Equal("boom", ex.Message);
+ }
+
+ ///
+ /// Verifies that when a is cached,
+ /// throws a cloned instance rather than the original, to avoid sharing stack traces across callers.
+ ///
+ [Fact]
+ public void Enter_WithSqlException_ThrowsClonedInstance()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ var original = SqlExceptionHelper.CreateSqlException("connection failed");
+
+ // Act
+ state.Enter(original);
+ var thrown = Assert.Throws(() => state.ThrowIfActive());
+
+ // Assert
+ Assert.NotSame(original, thrown);
+ Assert.Equal(original.Message, thrown.Message);
+ }
+
+ ///
+ /// Verifies that invokes the optional
+ /// onEnter callback after entering the blocking period.
+ ///
+ [Fact]
+ public void Enter_InvokesOnEnterCallback()
+ {
+ // Arrange
+ int callCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onEnter: () => callCount++);
+
+ // Act
+ state.Enter(new Exception());
+
+ // Assert
+ Assert.Equal(1, callCount);
+ }
+
+ ///
+ /// Verifies that calling a second time
+ /// replaces the cached exception, invokes the callback again, and the new exception is thrown.
+ ///
+ [Fact]
+ public void Enter_CalledTwice_ReplacesExceptionAndInvokesOnEnterAgain()
+ {
+ // Arrange
+ int enterCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onEnter: () => enterCount++);
+
+ // Act
+ state.Enter(new InvalidOperationException("first"));
+ state.Enter(new ArgumentException("second"));
+
+ // Assert
+ Assert.Equal(2, enterCount);
+ var ex = Assert.Throws(() => state.ThrowIfActive());
+ Assert.Equal("second", ex.Message);
+ }
+
+ ///
+ /// Verifies that does not invoke
+ /// the onExit callback (only the onEnter callback or the timer should trigger onExit).
+ ///
+ [Fact]
+ public void Enter_DoesNotInvokeOnExitCallback()
+ {
+ // Arrange
+ int exitCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onExit: () => exitCount++);
+
+ // Act
+ state.Enter(new Exception());
+
+ // Assert
+ Assert.Equal(0, exitCount);
+ }
+
+ #endregion
+
+ #region Clear
+
+ ///
+ /// Verifies that resets
+ /// to false.
+ ///
+ [Fact]
+ public void Clear_AfterEnter_ResetsHasError()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ state.Enter(new Exception());
+
+ // Act
+ state.Clear();
+
+ // Assert
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that after ,
+ /// does not throw because the cached error has been cleared.
+ ///
+ [Fact]
+ public void Clear_AfterEnter_ThrowIfActiveDoesNotThrow()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ state.Enter(new Exception());
+
+ // Act
+ state.Clear();
+
+ // Assert
+ state.ThrowIfActive(); // Must not throw
+ }
+
+ ///
+ /// Verifies that invokes the optional
+ /// onExit callback after clearing the error state.
+ ///
+ [Fact]
+ public void Clear_AfterEnter_InvokesOnExitCallback()
+ {
+ // Arrange
+ int exitCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onExit: () => exitCount++);
+ state.Enter(new Exception());
+
+ // Act
+ state.Clear();
+
+ // Assert
+ Assert.Equal(1, exitCount);
+ }
+
+ ///
+ /// Verifies that on an initial (no-error) state
+ /// does not invoke the onExit callback because there is nothing to clear.
+ ///
+ [Fact]
+ public void Clear_OnInitialState_DoesNotInvokeOnExitCallback()
+ {
+ // Arrange
+ int exitCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onExit: () => exitCount++);
+
+ // Act
+ state.Clear();
+
+ // Assert
+ Assert.Equal(0, exitCount);
+ }
+
+ ///
+ /// Verifies that is idempotent:
+ /// calling it a second time does not invoke the onExit callback again.
+ ///
+ [Fact]
+ public void Clear_CalledTwice_OnExitInvokedOnce()
+ {
+ // Arrange
+ int exitCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onExit: () => exitCount++);
+ state.Enter(new Exception());
+
+ // Act
+ state.Clear();
+ state.Clear();
+
+ // Assert
+ Assert.Equal(1, exitCount);
+ }
+
+ ///
+ /// Verifies that resets the backoff
+ /// timeout to its initial value, so the next
+ /// uses the initial wait duration instead of the accumulated backoff.
+ ///
+ [Fact]
+ public void Clear_ResetsBackoffSoNextEnterUsesInitialWait()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ state.Enter(new Exception("first"));
+
+ // Act
+ state.Clear();
+ state.Enter(new Exception("second"));
+
+ // Assert
+ Assert.True(state.HasError);
+ }
+
+ #endregion
+
+ #region Backoff progression
+
+ ///
+ /// Verifies that the initial enter schedules the timer with the 5-second initial wait.
+ /// The error state should persist until the timer fires, after which it clears automatically.
+ ///
+ [Fact]
+ public void Enter_FirstEntry_SchedulesInitialWaitTimer()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, timeProvider: fakeTime);
+ state.Enter(new Exception());
+
+ // Act: advance just under the initial 5s wait
+ fakeTime.Advance(TimeSpan.FromSeconds(4));
+
+ // Assert: timer has not fired yet
+ Assert.True(state.HasError);
+
+ // Act: advance past the due time
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+
+ // Assert: timer has fired, error cleared
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that successive timer-driven exits double the backoff each time:
+ /// 5 s → 10 s → 20 s → 40 s → 60 s (capped at MaxWait).
+ /// Each Enter schedules the timer for the current accumulated wait and the error
+ /// persists until exactly that duration elapses.
+ ///
+ [Fact]
+ public void Enter_BackoffDoublesOnSuccessiveTimerFiredEntries()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, timeProvider: fakeTime);
+
+ // (expectedWaitSeconds, _) — the wait used by Enter for that iteration
+ int[] expectedWaits = [5, 10, 20, 40, 60, 60];
+
+ // Act & Assert
+ foreach (int wait in expectedWaits)
+ {
+ state.Enter(new Exception($"attempt at wait={wait}s"));
+
+ // One second before due time: error still active
+ fakeTime.Advance(TimeSpan.FromSeconds(wait - 1));
+ Assert.True(state.HasError, $"HasError should be true after {wait - 1}s (scheduled wait={wait}s)");
+
+ // Final second: timer fires, error clears
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ Assert.False(state.HasError, $"HasError should be false after {wait}s (scheduled wait={wait}s)");
+ }
+ }
+
+ ///
+ /// Verifies that a timer-driven exit does NOT reset the backoff. The accumulated
+ /// backoff is preserved so the next failure uses the doubled wait, reflecting
+ /// continued instability. Only resets
+ /// the backoff to the initial value. In this way, we only reset the backoff when
+ /// a connection is successfully established.
+ ///
+ [Fact]
+ public void Enter_WhenTimerFires_DoesNotResetBackoff()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, timeProvider: fakeTime);
+
+ // First enter: uses 5s wait; _nextWait advances to 10s
+ state.Enter(new Exception("first"));
+ fakeTime.Advance(TimeSpan.FromSeconds(5)); // timer fires
+ Assert.False(state.HasError);
+
+ // Act: enter again — should use 10s, not the initial 5s
+ state.Enter(new Exception("second"));
+
+ // Assert: not cleared after 9s
+ fakeTime.Advance(TimeSpan.FromSeconds(9));
+ Assert.True(state.HasError);
+
+ // Assert: cleared after the full 10s
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that resets the backoff
+ /// to the initial 5-second wait even after the timer has doubled it, so the next
+ /// enter cycle starts fresh.
+ ///
+ [Fact]
+ public void Clear_AfterTimerFiredEntry_ResetsBackoffToInitialWait()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, timeProvider: fakeTime);
+
+ // First enter/timer-exit: _nextWait advances from 5s to 10s
+ state.Enter(new Exception("first"));
+ fakeTime.Advance(TimeSpan.FromSeconds(5));
+ Assert.False(state.HasError);
+
+ // Second enter to accumulate more backoff; then Clear resets it
+ state.Enter(new Exception("second")); // _nextWait advances to 20s
+ state.Clear(); // _nextWait resets to 5s
+
+ // Act: enter again — should use the initial 5s wait
+ state.Enter(new Exception("third"));
+
+ // Assert: not cleared after 4s
+ fakeTime.Advance(TimeSpan.FromSeconds(4));
+ Assert.True(state.HasError);
+
+ // Assert: cleared after the initial 5s
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ Assert.False(state.HasError);
+ }
+
+ #endregion
+
+ #region Timer behavior
+
+ ///
+ /// Verifies that the timer-driven exit invokes the onExit callback, the same
+ /// callback path used by .
+ ///
+ [Fact]
+ public void Enter_WhenTimerFires_InvokesOnExitCallback()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ int exitCount = 0;
+ using var state = new BlockingPeriodErrorState(
+ ownerPoolId: 1,
+ onExit: () => exitCount++,
+ timeProvider: fakeTime);
+ state.Enter(new Exception());
+
+ // Act
+ fakeTime.Advance(TimeSpan.FromSeconds(5));
+
+ // Assert
+ Assert.Equal(1, exitCount);
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that the timer does not fire before its due time, confirming the
+ /// scheduled wait is respected and not fired early.
+ ///
+ [Fact]
+ public void Enter_TimerDoesNotFireBeforeDueTime()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, timeProvider: fakeTime);
+ state.Enter(new Exception());
+
+ // Act: advance to 1ms before the 5s due time
+ fakeTime.Advance(TimeSpan.FromMilliseconds(4999));
+
+ // Assert
+ Assert.True(state.HasError);
+ }
+
+ #endregion
+
+ #region IDisposable
+
+ ///
+ /// Verifies that on an initial state
+ /// does not throw and completes successfully.
+ ///
+ [Fact]
+ public void Dispose_OnInitialState_DoesNotThrow()
+ {
+ // Arrange & Act
+ var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+
+ // Assert
+ state.Dispose(); // Should not throw
+ }
+
+ ///
+ /// Verifies that clears the cached
+ /// error state so is false after disposal.
+ ///
+ [Fact]
+ public void Dispose_AfterEnter_ClearsHasError()
+ {
+ // Arrange
+ var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ state.Enter(new Exception());
+
+ // Act
+ state.Dispose();
+
+ // Assert
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that is idempotent:
+ /// calling it multiple times does not throw and completes successfully.
+ ///
+ [Fact]
+ public void Dispose_CalledMultipleTimes_DoesNotThrow()
+ {
+ // Arrange & Act
+ var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ state.Dispose();
+
+ // Assert
+ state.Dispose(); // Must be idempotent and not throw
+ }
+
+ ///
+ /// Verifies that does not invoke
+ /// the onExit callback because disposal is a resource-cleanup path, not a logical
+ /// "exit blocking period" event.
+ ///
+ [Fact]
+ public void Dispose_DoesNotInvokeOnExitCallback()
+ {
+ // Arrange
+ int exitCount = 0;
+ var state = new BlockingPeriodErrorState(ownerPoolId: 1, onExit: () => exitCount++);
+ state.Enter(new Exception());
+
+ // Act
+ state.Dispose();
+
+ // Assert
+ Assert.Equal(0, exitCount);
+ }
+
+ ///
+ /// Verifies that properly releases
+ /// and cancels the internal exit timer, preventing stale callbacks from firing after disposal.
+ /// Uses to advance time deterministically past the timer's due
+ /// time without relying on real-time sleeps.
+ ///
+ [Fact]
+ public void Dispose_ReleasesTimer_NoCallbackAfterDispose()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ int exitCount = 0;
+ var state = new BlockingPeriodErrorState(
+ ownerPoolId: 1,
+ onExit: () => Interlocked.Increment(ref exitCount),
+ timeProvider: fakeTime);
+ state.Enter(new Exception());
+
+ // Act: dispose cancels the pending timer
+ state.Dispose();
+
+ // Advance well past the 5s due time — the cancelled timer must not fire
+ fakeTime.Advance(TimeSpan.FromSeconds(60));
+
+ // Assert
+ Assert.Equal(0, exitCount);
+ }
+
+ ///
+ /// Verifies that works correctly in a standard
+ /// using statement, with no exceptions thrown during disposal.
+ ///
+ [Fact]
+ public void Dispose_WithUsingStatement_DoesNotThrow()
+ {
+ // Arrange & Act
+ using (var state = new BlockingPeriodErrorState(ownerPoolId: 1))
+ {
+ state.Enter(new Exception());
+ Assert.True(state.HasError);
+ }
+
+ // Assert
+ // No exception expected when the using block exits
+ }
+
+ #endregion
+
+ #region Callback behaviour
+
+ ///
+ /// Verifies that both onEnter and onExit callbacks are optional (nullable)
+ /// and the instance works correctly when neither is provided.
+ ///
+ [Fact]
+ public void Callbacks_AreNotRequiredAndDefaultToNull()
+ {
+ // Arrange & Act
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 42);
+ state.Enter(new Exception());
+
+ // Assert
+ state.Clear(); // Should work without callbacks
+ }
+
+ ///
+ /// Verifies that the onEnter callback is invoked after the internal lock is released.
+ /// The callback reads and calls
+ /// — operations that are safe only
+ /// when the lock is not held. If the implementation were changed to hold the lock during
+ /// the callback invocation, any re-entrant call from the callback that tries to acquire the
+ /// same lock (on a non-re-entrant lock) would deadlock.
+ ///
+ [Fact]
+ public void OnEnter_CalledOutsideLock_CanCallBackIntoState()
+ {
+ // Arrange
+ bool hasErrorObservedInCallback = false;
+ BlockingPeriodErrorState? state = null;
+ using (state = new BlockingPeriodErrorState(
+ ownerPoolId: 1,
+ onEnter: () =>
+ {
+ // Observe state from inside the callback.
+ // HasError must already be true at this point.
+ hasErrorObservedInCallback = state!.HasError;
+
+ // Calling ThrowIfActive from the callback must not deadlock.
+ Assert.Throws(() => state.ThrowIfActive());
+ }))
+ {
+
+ // Act
+ state.Enter(new InvalidOperationException("test"));
+ }
+
+ // Assert
+ Assert.True(hasErrorObservedInCallback);
+ }
+
+ ///
+ /// Verifies that the onExit callback is invoked after the internal lock is released.
+ /// The callback reads — confirming it
+ /// observes the cleared state — and calls
+ /// without deadlocking. If the implementation were changed to hold the lock during the
+ /// callback, any re-entrant call from the callback would deadlock on a non-re-entrant lock.
+ ///
+ [Fact]
+ public void OnExit_CalledOutsideLock_CanCallBackIntoState()
+ {
+ // Arrange
+ bool hasErrorObservedInCallback = true; // initialized to true; must be false after Clear
+ BlockingPeriodErrorState? state = null;
+ using (state = new BlockingPeriodErrorState(
+ ownerPoolId: 1,
+ onExit: () =>
+ {
+ // HasError must already be false when onExit is called.
+ hasErrorObservedInCallback = state!.HasError;
+
+ // Calling ThrowIfActive from the callback must not deadlock.
+ state.ThrowIfActive(); // must not throw
+ }))
+ {
+
+ // Act
+ state.Enter(new Exception());
+ state.Clear();
+ }
+
+ // Assert
+ Assert.False(hasErrorObservedInCallback);
+ }
+
+ #endregion
+ }
+
+ ///
+ /// Test helper for creating instances. Since has
+ /// an internal constructor, instances must be created via the factory method.
+ ///
+ internal static class SqlExceptionHelper
+ {
+ ///
+ /// Creates a with the specified message using the internal factory method.
+ ///
+ /// The error message for the exception.
+ /// A new with the specified message.
+ internal static SqlException CreateSqlException(string message)
+ {
+ var collection = new SqlErrorCollection();
+ collection.Add(new SqlError(0, (byte)0, (byte)0, "TestServer", message, "", 0));
+ return SqlException.CreateException(collection, "");
+ }
+ }
+}
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolBlockingPeriodTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolBlockingPeriodTest.cs
new file mode 100644
index 0000000000..5adb2d8661
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolBlockingPeriodTest.cs
@@ -0,0 +1,401 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Data.Common;
+using System.Threading;
+using System.Transactions;
+using Microsoft.Data.ProviderBase;
+using Microsoft.Data.SqlClient.ConnectionPool;
+using Microsoft.Extensions.Time.Testing;
+using Xunit;
+
+#nullable enable
+
+namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool;
+
+///
+/// End-to-end coverage of the blocking-period
+/// behavior, exercising the full connection-acquisition path rather than the
+/// state machine in isolation. Verifies the wiring
+/// between a failed physical connection create and the pool's error state:
+/// - A failed create enters the blocking period and surfaces the error (CreateObject → Enter).
+/// - Subsequent requests fast-fail with the cached (cloned) exception without re-invoking the
+/// connection factory (error wait-handle → ThrowIfActive).
+/// - A successful create does not trip the blocking period.
+/// - bypasses the blocking period entirely so each
+/// request retries the factory.
+/// - After the blocking-period exit timer fires, the next request retries the factory and a
+/// successful create recovers the pool and resets the backoff (driven deterministically by an
+/// injected ).
+///
+public class WaitHandleDbConnectionPoolBlockingPeriodTest : IDisposable
+{
+ private const int DefaultMaxPoolSize = 50;
+ private const int DefaultMinPoolSize = 0;
+ private const int DefaultCreationTimeoutInMilliseconds = 15_000;
+
+ private WaitHandleDbConnectionPool? _pool;
+
+ public void Dispose()
+ {
+ _pool?.Shutdown();
+ _pool?.Clear();
+ }
+
+ ///
+ /// Builds a running backed by the supplied factory.
+ /// The controls the data source and
+ /// policy used to resolve whether blocking is enabled.
+ /// When is supplied, the pool's
+ /// uses it as its clock so the exit timer can be driven
+ /// deterministically; otherwise the system clock is used.
+ ///
+ private WaitHandleDbConnectionPool CreatePool(
+ SqlConnectionFactory connectionFactory,
+ string connectionString = "Data Source=localhost;",
+ TimeProvider? timeProvider = null)
+ {
+ var poolGroupOptions = new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: DefaultMinPoolSize,
+ maxPoolSize: DefaultMaxPoolSize,
+ creationTimeout: DefaultCreationTimeoutInMilliseconds,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true,
+ idleTimeout: 0);
+
+ var dbConnectionPoolGroup = new DbConnectionPoolGroup(
+ new SqlConnectionOptions(connectionString),
+ new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null),
+ poolGroupOptions);
+
+ var pool = new WaitHandleDbConnectionPool(
+ connectionFactory,
+ dbConnectionPoolGroup,
+ DbConnectionPoolIdentity.NoIdentity,
+ new DbConnectionPoolProviderInfo(),
+ timeProvider);
+
+ pool.Startup();
+ _pool = pool;
+ return pool;
+ }
+
+ ///
+ /// Synchronously requests a connection from the pool, mirroring the sync acquisition path
+ /// (taskCompletionSource == null) used by callers of SqlConnection.Open.
+ ///
+ private static bool TryGetConnectionSync(
+ WaitHandleDbConnectionPool pool,
+ DbConnection owner,
+ out DbConnectionInternal? connection)
+ {
+ TimeoutTimer timer = TimeoutTimer.StartNew(TimeSpan.FromSeconds(15));
+ return pool.TryGetConnection(owner, taskCompletionSource: null, timer, out connection);
+ }
+
+ ///
+ /// Verifies that when the connection factory fails, the pool enters the blocking period:
+ /// the originating exception is surfaced to the caller and
+ /// becomes true. Guards the CreateObject → wiring.
+ ///
+ [Fact]
+ public void TryGetConnection_WhenFactoryThrows_EntersBlockingPeriod()
+ {
+ // Arrange
+ SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable");
+ var factory = new ConfigurableSqlConnectionFactory(_ => throw failure);
+ var pool = CreatePool(factory);
+ using var owner = new SqlConnection();
+
+ // Act
+ SqlException thrown = Assert.Throws(
+ () => TryGetConnectionSync(pool, owner, out _));
+
+ // Assert
+ Assert.Equal(failure.Message, thrown.Message);
+ Assert.True(pool.ErrorOccurred);
+ Assert.Equal(1, factory.CreateConnectionCallCount);
+ }
+
+ ///
+ /// Verifies that once the pool is in the blocking period, a subsequent request fast-fails
+ /// with the cached exception without invoking the connection factory again. The first throw
+ /// rethrows the original instance; the fast-fail throw returns a clone (to avoid sharing stack
+ /// traces). Guards the error wait-handle → path.
+ ///
+ [Fact]
+ public void TryGetConnection_WhileBlocked_FastFailsWithCachedExceptionWithoutInvokingFactory()
+ {
+ // Arrange
+ SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable");
+ var factory = new ConfigurableSqlConnectionFactory(_ => throw failure);
+ var pool = CreatePool(factory);
+ using var owner = new SqlConnection();
+
+ // Act: first request enters the blocking period and rethrows the original exception.
+ SqlException first = Assert.Throws(
+ () => TryGetConnectionSync(pool, owner, out _));
+
+ // Second request must fast-fail from the cached state without reaching the factory.
+ SqlException second = Assert.Throws(
+ () => TryGetConnectionSync(pool, owner, out _));
+
+ // Assert
+ Assert.Same(failure, first); // original instance rethrown on entry
+ Assert.NotSame(failure, second); // cloned on fast-fail
+ Assert.Equal(failure.Message, second.Message); // but message is preserved
+ Assert.Equal(1, factory.CreateConnectionCallCount); // factory not re-invoked while blocked
+ Assert.True(pool.ErrorOccurred);
+ }
+
+ ///
+ /// Verifies that with , a failed create does not
+ /// cache the error: stays false and each
+ /// request retries the connection factory. Guards the
+ /// IsBlockingPeriodEnabled() == false bypass in CreateObject.
+ ///
+ [Fact]
+ public void TryGetConnection_WithNeverBlockPolicy_DoesNotCacheErrorAndRetriesFactory()
+ {
+ // Arrange
+ SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable");
+ var factory = new ConfigurableSqlConnectionFactory(_ => throw failure);
+ var pool = CreatePool(factory, "Data Source=localhost;PoolBlockingPeriod=NeverBlock");
+ using var owner = new SqlConnection();
+
+ // Act: two independent failures, neither of which should be cached.
+ SqlException first = Assert.Throws(() => TryGetConnectionSync(pool, owner, out _));
+ SqlException second = Assert.Throws(() => TryGetConnectionSync(pool, owner, out _));
+
+ // Assert
+ Assert.Same(failure, first); // original instance rethrown, never cached/cloned
+ Assert.Same(failure, second); // original instance rethrown again on retry
+ Assert.False(pool.ErrorOccurred);
+ Assert.Equal(2, factory.CreateConnectionCallCount);
+ }
+
+ ///
+ /// Verifies that a successful create returns a connection and does not trip the blocking
+ /// period, confirming the normal path leaves
+ /// false (and exercises the fast no-op on success).
+ ///
+ [Fact]
+ public void TryGetConnection_WhenFactorySucceeds_DoesNotEnterBlockingPeriod()
+ {
+ // Arrange
+ var factory = new ConfigurableSqlConnectionFactory(_ => new MockDbConnectionInternal());
+ var pool = CreatePool(factory);
+ using var owner = new SqlConnection();
+
+ // Act
+ bool completed = TryGetConnectionSync(pool, owner, out DbConnectionInternal? connection);
+
+ // Assert
+ Assert.True(completed);
+ Assert.NotNull(connection);
+ Assert.False(pool.ErrorOccurred);
+ Assert.Equal(1, factory.CreateConnectionCallCount);
+ }
+
+ ///
+ /// Verifies that once the blocking period's exit timer fires, the next request retries the
+ /// factory and a successful create recovers the pool:
+ /// returns to false and a connection is produced. Drives the exit timer deterministically with
+ /// an injected , guarding the
+ /// timer-exit → retry → Clear path at the pool level.
+ ///
+ [Fact]
+ public void TryGetConnection_AfterBlockingPeriodExpires_RetriesFactoryAndRecovers()
+ {
+ // Arrange: first create fails, every later create succeeds.
+ SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable");
+ var factory = new ConfigurableSqlConnectionFactory(
+ call => call == 1 ? throw failure : new MockDbConnectionInternal());
+ var fakeTime = new FakeTimeProvider();
+ var pool = CreatePool(factory, timeProvider: fakeTime);
+ using var owner = new SqlConnection();
+
+ // Act: first request enters the blocking period.
+ Assert.Throws(() => TryGetConnectionSync(pool, owner, out _));
+ Assert.True(pool.ErrorOccurred);
+ Assert.Equal(1, factory.CreateConnectionCallCount);
+
+ // While blocked, a request fast-fails without reaching the factory.
+ Assert.Throws(() => TryGetConnectionSync(pool, owner, out _));
+ Assert.Equal(1, factory.CreateConnectionCallCount);
+
+ // Advance past the initial 5s blocking period so the exit timer fires.
+ fakeTime.Advance(TimeSpan.FromSeconds(5));
+
+ // Assert: the pool has recovered and a new request reaches the factory and succeeds.
+ Assert.False(pool.ErrorOccurred);
+ bool completed = TryGetConnectionSync(pool, owner, out DbConnectionInternal? connection);
+ Assert.True(completed);
+ Assert.NotNull(connection);
+ Assert.False(pool.ErrorOccurred);
+ Assert.Equal(2, factory.CreateConnectionCallCount);
+ }
+
+ ///
+ /// Verifies that a successful create resets the exponential backoff to the initial 5-second
+ /// wait. The backoff is first allowed to grow (fail → timer-exit → fail → timer-exit, observing
+ /// the doubled 10-second wait) so the reset is observable. A successful create then resets the
+ /// backoff via , and a subsequent failure blocks for
+ /// only the initial 5 seconds rather than the accumulated 20 seconds, confirming the success
+ /// reset is wired through the pool. Drives timing deterministically with an injected
+ /// .
+ ///
+ [Fact]
+ public void TryGetConnection_SuccessfulCreate_ResetsBackoffToInitialWait()
+ {
+ // Arrange: creates #1, #2 and #4 fail; #3 succeeds (recovery) to reset the backoff.
+ SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable");
+ var factory = new ConfigurableSqlConnectionFactory(
+ call => call == 3 ? new MockDbConnectionInternal() : throw failure);
+ var fakeTime = new FakeTimeProvider();
+ var pool = CreatePool(factory, timeProvider: fakeTime);
+ using var owner = new SqlConnection();
+
+ // Act: first failure enters the blocking period with the initial 5s wait.
+ Assert.Throws(() => TryGetConnectionSync(pool, owner, out _));
+ Assert.Equal(1, factory.CreateConnectionCallCount);
+ fakeTime.Advance(TimeSpan.FromSeconds(5)); // timer fires -> backoff doubles to 10s
+ Assert.False(pool.ErrorOccurred);
+
+ // Second failure enters with the doubled 10s wait; confirm it lasts the full 10s so the
+ // backoff has demonstrably grown before we reset it.
+ Assert.Throws(() => TryGetConnectionSync(pool, owner, out _)); // create #2 fails
+ Assert.Equal(2, factory.CreateConnectionCallCount);
+ fakeTime.Advance(TimeSpan.FromSeconds(9));
+ Assert.True(pool.ErrorOccurred); // still blocked at 9s -> wait is 10s, not 5s
+ fakeTime.Advance(TimeSpan.FromSeconds(1)); // timer fires at 10s -> backoff would double to 20s
+ Assert.False(pool.ErrorOccurred);
+
+ // A successful create resets the backoff to the initial 5s.
+ Assert.True(TryGetConnectionSync(pool, owner, out _)); // create #3 succeeds -> Clear()
+ Assert.Equal(3, factory.CreateConnectionCallCount);
+
+ // A new failure enters the blocking period again.
+ Assert.Throws(() => TryGetConnectionSync(pool, owner, out _)); // create #4 fails
+ Assert.Equal(4, factory.CreateConnectionCallCount);
+ Assert.True(pool.ErrorOccurred);
+
+ // Assert: the wait was reset to the initial 5s (not the accumulated 20s).
+ fakeTime.Advance(TimeSpan.FromSeconds(4));
+ Assert.True(pool.ErrorOccurred); // still blocked just before the 5s mark
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ Assert.False(pool.ErrorOccurred); // cleared exactly at 5s, proving the backoff reset
+ }
+
+ ///
+ /// Verifies that a timer-driven exit alone does NOT reset the backoff: when a failure recurs
+ /// after the exit timer fires (with no intervening successful create), the next blocking period
+ /// uses the doubled wait (10s, not the initial 5s). Confirms that only
+ /// — invoked on a successful create — resets the
+ /// backoff at the pool level. Drives timing deterministically with an injected
+ /// .
+ ///
+ [Fact]
+ public void TryGetConnection_FailingAgainAfterExitTimer_StillDoublesBackoff()
+ {
+ // Arrange: every create fails, so the backoff is never reset by a success.
+ SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable");
+ var factory = new ConfigurableSqlConnectionFactory(_ => throw failure);
+ var fakeTime = new FakeTimeProvider();
+ var pool = CreatePool(factory, timeProvider: fakeTime);
+ using var owner = new SqlConnection();
+
+ // Act: first failure enters the blocking period (initial 5s wait).
+ Assert.Throws(() => TryGetConnectionSync(pool, owner, out _));
+ Assert.Equal(1, factory.CreateConnectionCallCount);
+
+ // Advance past the 5s wait so the exit timer fires (no success -> backoff not reset).
+ fakeTime.Advance(TimeSpan.FromSeconds(5));
+ Assert.False(pool.ErrorOccurred);
+
+ // A new failure re-enters the blocking period using the doubled 10s wait. The factory is
+ // invoked again, confirming the request reached creation rather than fast-failing.
+ Assert.Throws(() => TryGetConnectionSync(pool, owner, out _));
+ Assert.Equal(2, factory.CreateConnectionCallCount);
+ Assert.True(pool.ErrorOccurred);
+
+ // Assert: still blocked at the original 5s mark (proves the wait is not 5s)...
+ fakeTime.Advance(TimeSpan.FromSeconds(5));
+ Assert.True(pool.ErrorOccurred);
+
+ // ...and not cleared until the full doubled 10s elapses.
+ fakeTime.Advance(TimeSpan.FromSeconds(4));
+ Assert.True(pool.ErrorOccurred);
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ Assert.False(pool.ErrorOccurred); // cleared exactly at 10s, proving the backoff doubled
+ }
+
+ ///
+ /// test double whose CreateConnection behavior is
+ /// supplied per-call (to throw or return a mock connection) and which records how many times
+ /// the factory was invoked so tests can assert fast-fail (no re-invocation) versus retry.
+ ///
+ internal sealed class ConfigurableSqlConnectionFactory : SqlConnectionFactory
+ {
+ private readonly Func _createBehavior;
+ private int _callCount;
+
+ ///
+ /// The number of times CreateConnection has been invoked. The argument passed to
+ /// the behavior delegate is the 1-based invocation index.
+ ///
+ internal int CreateConnectionCallCount => Volatile.Read(ref _callCount);
+
+ internal ConfigurableSqlConnectionFactory(Func createBehavior)
+ => _createBehavior = createBehavior;
+
+ protected override DbConnectionInternal CreateConnection(
+ SqlConnectionOptions options,
+ ConnectionPoolKey poolKey,
+ DbConnectionPoolGroupProviderInfo poolGroupProviderInfo,
+ IDbConnectionPool pool,
+ DbConnection owningConnection,
+ TimeoutTimer timeout)
+ {
+ int call = Interlocked.Increment(ref _callCount);
+ return _createBehavior(call);
+ }
+ }
+
+ ///
+ /// Minimal stub used as a successful create result.
+ /// Duplicated locally so this test file remains self-contained, mirroring the helpers in the
+ /// sibling WaitHandleDbConnectionPool test files.
+ ///
+ internal sealed class MockDbConnectionInternal : DbConnectionInternal
+ {
+ public override string ServerVersion => "Mock";
+
+ public override DbTransaction BeginTransaction(System.Data.IsolationLevel il)
+ => throw new NotImplementedException();
+
+ public override void EnlistTransaction(Transaction? transaction)
+ {
+ if (transaction != null)
+ {
+ EnlistedTransaction = transaction;
+ }
+ }
+
+ protected override void Activate(Transaction? transaction)
+ {
+ EnlistedTransaction = transaction;
+ }
+
+ protected override void Deactivate()
+ {
+ }
+
+ internal override void ResetConnection()
+ {
+ }
+ }
+}