diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml
index a68c0a323b..98daa768b5 100644
--- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml
+++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml
@@ -979,6 +979,31 @@ The following example converts an existing connection string from using SQL Serv
+
+
+ Gets or sets the maximum time, in seconds, that a connection can sit unused (idle) in the connection pool before it is discarded. The default is 300 (5 minutes).
+
+
+ The idle timeout for pooled connections, in seconds.
+
+
+
+ This property corresponds to the "Connection Idle Timeout" key within the connection string.
+
+
+ In versions where the AppContext switch Switch.Microsoft.Data.SqlClient.UseLegacyIdleTimeoutBehavior is enabled (the default), the driver preserves historical pooling behavior and does not enforce this setting. Set the switch to to enable idle-timeout enforcement.
+
+
+ The driver makes a best effort to discard connections that have remained idle in the pool for longer than this value. The exact point in the connection lifecycle at which the check occurs is an implementation detail and may change over time. This protects callers from receiving connections that may have been silently closed by firewalls, load balancers, or server-side inactivity thresholds.
+
+
+ A value of zero (0) disables idle expiration; connections are kept in the pool indefinitely (subject to other expiry rules such as ).
+
+
+ Idle timeout operates independently of . Whichever threshold is exceeded first causes the connection to be discarded.
+
+
+
Gets or sets the maximum number of connections allowed in the connection pool for this specific connection string.
diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs
index e52cbdc8d3..96ac073744 100644
--- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs
+++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs
@@ -1368,6 +1368,10 @@ public SqlConnectionStringBuilder(string connectionString) { }
[System.ComponentModel.DisplayNameAttribute("Load Balance Timeout")]
[System.ComponentModel.RefreshPropertiesAttribute(System.ComponentModel.RefreshProperties.All)]
public int LoadBalanceTimeout { get { throw null; } set { } }
+ ///
+ [System.ComponentModel.DisplayNameAttribute("Connection Idle Timeout")]
+ [System.ComponentModel.RefreshPropertiesAttribute(System.ComponentModel.RefreshProperties.All)]
+ public int IdleTimeout { get { throw null; } set { } }
///
[System.ComponentModel.DisplayNameAttribute("Max Pool Size")]
[System.ComponentModel.RefreshPropertiesAttribute(System.ComponentModel.RefreshProperties.All)]
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs
index 460fa0c2cc..bf700c0b55 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs
@@ -36,6 +36,9 @@ internal static class DbConnectionStringDefaults
internal const bool IntegratedSecurity = false;
internal const SqlConnectionIPAddressPreference IpAddressPreference = SqlConnectionIPAddressPreference.IPv4First;
internal const int LoadBalanceTimeout = 0; // default of 0 means don't use
+ // Default configured idle timeout is 5 minutes. Connection pool behavior is gated by
+ // LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior for compatibility.
+ internal const int IdleTimeout = 300;
internal const int MaxPoolSize = 100;
internal const int MinPoolSize = 0;
internal const bool MultipleActiveResultSets = false;
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringKeywords.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringKeywords.cs
index 5c902de2a8..b163ec2536 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringKeywords.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringKeywords.cs
@@ -31,6 +31,7 @@ internal static class DbConnectionStringKeywords
internal const string IntegratedSecurity = "Integrated Security";
internal const string IpAddressPreference = "IP Address Preference";
internal const string LoadBalanceTimeout = "Load Balance Timeout";
+ internal const string IdleTimeout = "Connection Idle Timeout";
internal const string MaxPoolSize = "Max Pool Size";
internal const string MinPoolSize = "Min Pool Size";
internal const string MultipleActiveResultSets = "Multiple Active Result Sets";
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs
index b295f84b3d..539f79ff47 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs
@@ -82,6 +82,11 @@ internal DbConnectionInternal(ConnectionState state, bool hidePassword, bool all
ShouldHidePassword = hidePassword;
State = state;
CreateTime = DateTime.UtcNow;
+ // Initialize the returned-to-pool stamp to creation time so that a freshly built connection is treated
+ // as "just used" by the pool's idle-expiry checks until the pool's return path stamps it again on first return.
+ // Without this initialization, ReturnedTime would default to DateTime.MinValue, which would cause
+ // IsLiveConnection to immediately evict every new connection whenever IdleTimeout is configured.
+ ReturnedTime = CreateTime;
}
#region Properties
@@ -91,6 +96,15 @@ internal DbConnectionInternal(ConnectionState state, bool hidePassword, bool all
///
internal DateTime CreateTime { get; }
+ ///
+ /// UTC timestamp of when this connection was last returned to the pool.
+ /// Stamped by . Initialized to in the constructor
+ /// so a freshly built connection is treated as "just used" until its first return.
+ /// Internal setter exists to support deterministic unit tests without reflection.
+ /// The pool reads this value to decide whether the connection has sat idle longer than the configured idle timeout.
+ ///
+ internal DateTime ReturnedTime { get; set; }
+
///
/// The pool generation at the time this connection was created or added to the pool.
/// Used by to detect stale connections after a pool clear.
@@ -726,6 +740,17 @@ internal virtual void PrepareForReplaceConnection()
// By default, there is no preparation required
}
+ ///
+ /// Stamps with the current UTC time. The pool calls this from its
+ /// return-to-pool path only when it intends the idle-timeout machinery to act on the value;
+ /// the connection owns the mechanism (recording the time) while the pool owns the policy
+ /// (deciding when a stamp is meaningful).
+ ///
+ internal void SetReturnedTime()
+ {
+ ReturnedTime = DateTime.UtcNow;
+ }
+
internal void PrePush(DbConnection expectedOwner)
{
// Called by IDbConnectionPool when we're about to be put into it's pool, we take this
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs
index 3218bf461b..9169e9717e 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs
@@ -244,6 +244,18 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti
{
ValidateOwnershipAndSetPoolingState(connection, owningObject);
+ // Stamp the return time before IsLiveConnection runs so the idle-expiry gate inside it
+ // measures time-in-pool, not time-since-last-return. Without this, a connection whose
+ // checkout exceeded IdleTimeout (e.g. a long-running query) would be wrongly evicted on
+ // return even though it was actively in use on the wire. The same gating conditions are
+ // applied here as in IsLiveConnection so we avoid the per-return DateTime.UtcNow when
+ // idle expiry is disabled or the legacy idle-timeout behavior is in effect.
+ if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior &&
+ PoolGroupOptions.IdleTimeout != TimeSpan.Zero)
+ {
+ connection.SetReturnedTime();
+ }
+
if (!IsLiveConnection(connection))
{
RemoveConnection(connection);
@@ -453,6 +465,22 @@ public bool TryGetConnection(
/// Returns true if the connection is live and unexpired, otherwise returns false.
private bool IsLiveConnection(DbConnectionInternal connection)
{
+ // Connection has been sitting idle longer than the configured idle timeout.
+ // Checked before the (potentially expensive) liveness probe so an idle-expired
+ // connection is discarded without an SNI round-trip.
+ // ReturnedTime is initialized to CreateTime so a freshly minted connection never trips this
+ // check on first retrieval, and is then stamped by ReturnInternalConnection on every return.
+ // Use subtraction rather than addition so the comparison cannot throw if ReturnedTime is
+ // ever close to DateTime.MaxValue. A clock skew that leaves ReturnedTime in the future
+ // produces a negative TimeSpan, which falls through as not-expired (fail safe).
+ TimeSpan idleTimeout = PoolGroupOptions.IdleTimeout;
+ if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior &&
+ idleTimeout != TimeSpan.Zero &&
+ DateTime.UtcNow - connection.ReturnedTime > idleTimeout)
+ {
+ return false;
+ }
+
// Broken physical connection
if (!connection.IsConnectionAlive())
{
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs
index 5ac6f4d565..b1790948b2 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs
@@ -13,6 +13,7 @@ internal sealed class DbConnectionPoolGroupOptions
private readonly int _maxPoolSize;
private readonly int _creationTimeout;
private readonly TimeSpan _loadBalanceTimeout;
+ private readonly TimeSpan _idleTimeout;
private readonly bool _hasTransactionAffinity;
private readonly bool _useLoadBalancing;
@@ -22,7 +23,8 @@ public DbConnectionPoolGroupOptions(
int maxPoolSize,
int creationTimeout,
int loadBalanceTimeout,
- bool hasTransactionAffinity
+ bool hasTransactionAffinity,
+ int idleTimeout
)
{
_poolByIdentity = poolByIdentity;
@@ -36,6 +38,16 @@ bool hasTransactionAffinity
_useLoadBalancing = true;
}
+ if (idleTimeout < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(idleTimeout), idleTimeout, "Idle timeout cannot be negative.");
+ }
+
+ if (idleTimeout != 0)
+ {
+ _idleTimeout = TimeSpan.FromSeconds(idleTimeout);
+ }
+
_hasTransactionAffinity = hasTransactionAffinity;
}
@@ -54,6 +66,20 @@ public TimeSpan LoadBalanceTimeout
{
get { return _loadBalanceTimeout; }
}
+ ///
+ /// The maximum time a pooled connection can sit unused (idle) in the pool before it becomes
+ /// eligible for eviction. Eviction is best-effort: a connection that has been idle longer
+ /// than this value is discarded either on the next retrieval attempt or during a periodic
+ /// pool maintenance pass (in pool implementations that perform periodic maintenance),
+ /// whichever happens first. Implementations may check this threshold opportunistically, so
+ /// eviction may occur somewhat before or after the exact timeout depending on the pool
+ /// implementation and maintenance cadence.
+ /// disables idle expiration.
+ ///
+ public TimeSpan IdleTimeout
+ {
+ get { return _idleTimeout; }
+ }
public int MaxPoolSize
{
get { return _maxPoolSize; }
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 ecf8840ebb..d2c3b57323 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
@@ -223,8 +223,25 @@ internal WaitHandleDbConnectionPool(
lock (s_random)
{
- // Random.Next is not thread-safe
- _cleanupWait = s_random.Next(12, 24) * 10 * 1000; // 2-4 minutes in 10 sec intervals, WebData 103603
+ TimeSpan idleTimeout = connectionPoolGroup.PoolGroupOptions.IdleTimeout;
+ if (LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior || idleTimeout == TimeSpan.Zero)
+ {
+ // Historical 2-4 minute random cleanup window. Used for the legacy switch and for
+ // the "idle eviction disabled" state (IdleTimeout=0) where the timer still runs for
+ // non-idle maintenance but CleanupCallback skips the generational sweep.
+ _cleanupWait = s_random.Next(12, 24) * 10 * 1000; // 2-4 minutes in 10 sec intervals, WebData 103603
+ }
+ else
+ {
+ // New idle-timeout behavior with a configured value: the WaitHandle pool takes two
+ // pruning cycles to evict an idle connection (new->old generation, then old->closed),
+ // so halve the configured timeout to approximate the requested idle lifetime. Floor
+ // the period at 1 second so small IdleTimeout values (e.g. 1s) don't schedule a
+ // sub-second timer that wakes the threadpool just to walk empty stacks.
+ long cleanupWaitMilliseconds = (long)idleTimeout.TotalMilliseconds / 2;
+ cleanupWaitMilliseconds = Math.Max(cleanupWaitMilliseconds, 1000);
+ _cleanupWait = cleanupWaitMilliseconds >= int.MaxValue ? int.MaxValue : (int)cleanupWaitMilliseconds;
+ }
}
_connectionFactory = connectionFactory;
@@ -351,8 +368,16 @@ private void CleanupCallback(object state)
// at least one period but not more than two periods.
SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}", Id);
+ // Idle eviction (the generational destroy/age-into-old-stack sweep below) is enabled under
+ // the legacy switch, or when the new behavior is on with a non-zero IdleTimeout. When the
+ // new behavior is on and IdleTimeout is 0, eviction is disabled entirely: skip the sweep
+ // and only run the MinPoolSize floor maintenance.
+ bool idleEvictionEnabled =
+ LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior ||
+ PoolGroupOptions.IdleTimeout != TimeSpan.Zero;
+
// Destroy free objects that put us above MinPoolSize from old stack.
- while (Count > MinPoolSize)
+ while (idleEvictionEnabled && Count > MinPoolSize)
{
// While above MinPoolSize...
if (_waitHandles.PoolSemaphore.WaitOne(0, false))
@@ -363,6 +388,7 @@ private void CleanupCallback(object state)
if (_stackOld.TryPop(out obj))
{
Debug.Assert(obj != null, "null connection is not expected");
+
// If we obtained one from the old stack, destroy it.
SqlClientDiagnostics.Metrics.ExitFreeConnection();
@@ -414,7 +440,7 @@ private void CleanupCallback(object state)
// Push to the old-stack. For each free object, move object from
// new stack to old stack.
- if (_waitHandles.PoolSemaphore.WaitOne(0, false))
+ if (idleEvictionEnabled && _waitHandles.PoolSemaphore.WaitOne(0, false))
{
for (; ; )
{
@@ -667,6 +693,10 @@ private void DeactivateObject(DbConnectionInternal obj)
// DelegatedTransactionEnded event will clean up the
// connection appropriately regardless of the pool state.
Debug.Assert(_transactedConnectionPool != null, "Transacted connection pool was not expected to be null.");
+ // Transacting connections are held in their own store and are never
+ // proactively closed (doing so would abort the transaction, which can be
+ // distributed). Idle-timeout enforcement does not apply here, so we do
+ // not call SetReturnedTime when parking the connection in the transacted pool.
_transactedConnectionPool.PutTransactedObject(transaction, obj);
rootTxn = true;
}
@@ -1061,9 +1091,11 @@ private bool TryGetConnection(DbConnection owningObject, uint waitForMultipleObj
Interlocked.Decrement(ref _waitCount);
obj = GetFromGeneralPool();
- if ((obj != null) && (!obj.IsConnectionAlive()))
+ bool isIdleExpired = obj != null && IsIdleExpired(obj);
+ if ((obj != null) && (isIdleExpired || !obj.IsConnectionAlive()))
{
- SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, found dead and removed.", Id, obj.ObjectID);
+ string reason = isIdleExpired ? "idle-expired" : "found dead";
+ SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, {2} and removed.", Id, obj.ObjectID, reason);
DestroyObject(obj);
obj = null; // Setting to null in case creating a new object fails
@@ -1243,6 +1275,8 @@ private DbConnectionInternal GetFromTransactedPool(out Transaction transaction)
}
else if (!obj.IsConnectionAlive())
{
+ // Transacting connections are exempt from idle-timeout eviction (closing them
+ // would abort the transaction, possibly distributed). Only liveness is checked here.
SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, found dead and removed.", Id, obj.ObjectID);
DestroyObject(obj);
obj = null;
@@ -1363,6 +1397,16 @@ private void PutNewObject(DbConnectionInternal obj)
SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, Pushing to general pool.", Id, obj.ObjectID);
+ // Stamp the return time so IsIdleExpired can later decide whether the connection has sat
+ // unused too long. Skip the stamp when idle expiry is disabled or the legacy idle-timeout
+ // behavior is in effect to avoid the per-return DateTime.UtcNow on the hot return path;
+ // IsIdleExpired short-circuits on the same conditions so the value would be unread in
+ // those cases.
+ if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior &&
+ PoolGroupOptions.IdleTimeout != TimeSpan.Zero)
+ {
+ obj.SetReturnedTime();
+ }
_stackNew.Push(obj);
_waitHandles.PoolSemaphore.Release(1);
@@ -1370,6 +1414,23 @@ private void PutNewObject(DbConnectionInternal obj)
}
+ ///
+ /// Returns true when the supplied connection has been sitting idle in the pool longer than the
+ /// configured . Returns false when idle
+ /// eviction is off - that is, when the
+ /// switch is enabled or is zero.
+ ///
+ private bool IsIdleExpired(DbConnectionInternal obj)
+ {
+ // Use subtraction rather than addition so the comparison cannot throw if ReturnedTime is
+ // ever close to DateTime.MaxValue. A clock skew that leaves ReturnedTime in the future
+ // produces a negative TimeSpan, which falls through as not-expired (fail safe).
+ TimeSpan idleTimeout = PoolGroupOptions.IdleTimeout;
+ return !LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior &&
+ idleTimeout != TimeSpan.Zero &&
+ DateTime.UtcNow - obj.ReturnedTime > idleTimeout;
+ }
+
public void ReturnInternalConnection(DbConnectionInternal obj, DbConnection owningObject)
{
Debug.Assert(obj != null, "null obj?");
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs
index df762ed7ae..d951bb5d30 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs
@@ -125,6 +125,13 @@ internal static class LocalAppContextSwitches
private const string UseConnectionPoolV2String =
"Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2";
+ ///
+ /// The name of the app context switch that controls whether to preserve
+ /// legacy idle-timeout behavior in connection pooling.
+ ///
+ private const string UseLegacyIdleTimeoutBehaviorString =
+ "Switch.Microsoft.Data.SqlClient.UseLegacyIdleTimeoutBehavior";
+
///
/// The name of the app context switch that controls whether pool operations
/// should count against the caller's overall ConnectTimeout budget.
@@ -241,6 +248,11 @@ private enum SwitchValue : byte
///
private static SwitchValue s_useConnectionPoolV2 = SwitchValue.None;
+ ///
+ /// The cached value of the UseLegacyIdleTimeoutBehavior switch.
+ ///
+ private static SwitchValue s_useLegacyIdleTimeoutBehavior = SwitchValue.None;
+
///
/// The cached value of the UseOverallConnectTimeoutForPoolWait switch.
///
@@ -576,6 +588,16 @@ public static bool UseCompatibilityAsyncBehaviour
defaultValue: false,
ref s_useConnectionPoolV2);
+ ///
+ /// When set to true (the default), pooling preserves historical idle-timeout behavior.
+ /// When set to false, configured Connection Idle Timeout is enforced by the pool.
+ ///
+ public static bool UseLegacyIdleTimeoutBehavior =>
+ AcquireAndReturn(
+ UseLegacyIdleTimeoutBehaviorString,
+ defaultValue: true,
+ ref s_useLegacyIdleTimeoutBehavior);
+
///
/// When set to true, pool operations count against the
/// caller's ConnectTimeout budget. This includes waits and async operations.
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs
index 4cdee8bdc2..1f90eac202 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs
@@ -740,7 +740,8 @@ private static DbConnectionPoolGroupOptions CreateConnectionPoolGroupOptions(Sql
opt.MaxPoolSize,
connectionTimeout,
opt.LoadBalanceTimeout,
- opt.Enlist);
+ opt.Enlist,
+ opt.IdleTimeout);
}
return poolingOptions;
}
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs
index 6eafb195c8..734ecc125f 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs
@@ -105,6 +105,7 @@ internal static class TRANSACTIONBINDING
private readonly int _commandTimeout;
private readonly int _connectTimeout;
private readonly int _loadBalanceTimeout;
+ private readonly int _idleTimeout;
private readonly int _maxPoolSize;
private readonly int _minPoolSize;
private readonly int _packetSize;
@@ -195,6 +196,7 @@ static SqlConnectionOptions()
DbConnectionStringSynonyms.IpAddressPreference);
AddKeywordToMap(DbConnectionStringKeywords.LoadBalanceTimeout,
DbConnectionStringSynonyms.ConnectionLifetime);
+ AddKeywordToMap(DbConnectionStringKeywords.IdleTimeout);
AddKeywordToMap(DbConnectionStringKeywords.MultipleActiveResultSets,
DbConnectionStringSynonyms.MultipleActiveResultSets);
AddKeywordToMap(DbConnectionStringKeywords.MaxPoolSize);
@@ -274,6 +276,7 @@ internal SqlConnectionOptions(string connectionString)
_commandTimeout = ConvertValueToInt32(DbConnectionStringKeywords.CommandTimeout, DbConnectionStringDefaults.CommandTimeout);
_connectTimeout = ConvertValueToInt32(DbConnectionStringKeywords.ConnectTimeout, DbConnectionStringDefaults.ConnectTimeout);
_loadBalanceTimeout = ConvertValueToInt32(DbConnectionStringKeywords.LoadBalanceTimeout, DbConnectionStringDefaults.LoadBalanceTimeout);
+ _idleTimeout = ConvertValueToInt32(DbConnectionStringKeywords.IdleTimeout, DbConnectionStringDefaults.IdleTimeout);
_maxPoolSize = ConvertValueToInt32(DbConnectionStringKeywords.MaxPoolSize, DbConnectionStringDefaults.MaxPoolSize);
_minPoolSize = ConvertValueToInt32(DbConnectionStringKeywords.MinPoolSize, DbConnectionStringDefaults.MinPoolSize);
_packetSize = ConvertValueToInt32(DbConnectionStringKeywords.PacketSize, DbConnectionStringDefaults.PacketSize);
@@ -318,6 +321,11 @@ internal SqlConnectionOptions(string connectionString)
throw ADP.InvalidConnectionOptionValue(DbConnectionStringKeywords.LoadBalanceTimeout);
}
+ if (_idleTimeout < 0)
+ {
+ throw ADP.InvalidConnectionOptionValue(DbConnectionStringKeywords.IdleTimeout);
+ }
+
if (_connectTimeout < 0)
{
throw ADP.InvalidConnectionOptionValue(DbConnectionStringKeywords.ConnectTimeout);
@@ -579,6 +587,7 @@ internal SqlConnectionOptions(SqlConnectionOptions connectionOptions, string dat
_commandTimeout = connectionOptions._commandTimeout;
_connectTimeout = connectionOptions._connectTimeout;
_loadBalanceTimeout = connectionOptions._loadBalanceTimeout;
+ _idleTimeout = connectionOptions._idleTimeout;
_poolBlockingPeriod = connectionOptions._poolBlockingPeriod;
_maxPoolSize = connectionOptions._maxPoolSize;
_minPoolSize = connectionOptions._minPoolSize;
@@ -650,6 +659,9 @@ internal SqlConnectionOptions(SqlConnectionOptions connectionOptions, string dat
internal int CommandTimeout => _commandTimeout;
internal int ConnectTimeout => _connectTimeout;
internal int LoadBalanceTimeout => _loadBalanceTimeout;
+ // Maximum time (in seconds) a connection can sit idle in the pool before it is discarded.
+ // 0 disables idle expiration.
+ internal int IdleTimeout => _idleTimeout;
internal int MaxPoolSize => _maxPoolSize;
internal int MinPoolSize => _minPoolSize;
internal int PacketSize => _packetSize;
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs
index 4e54a32c75..4038dbb295 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs
@@ -50,6 +50,7 @@ private enum Keywords
ServerCertificate,
TrustServerCertificate,
LoadBalanceTimeout,
+ IdleTimeout,
PacketSize,
TypeSystemVersion,
Authentication,
@@ -101,6 +102,7 @@ private enum Keywords
private int _commandTimeout = DbConnectionStringDefaults.CommandTimeout;
private int _connectTimeout = DbConnectionStringDefaults.ConnectTimeout;
private int _loadBalanceTimeout = DbConnectionStringDefaults.LoadBalanceTimeout;
+ private int _idleTimeout = DbConnectionStringDefaults.IdleTimeout;
private int _maxPoolSize = DbConnectionStringDefaults.MaxPoolSize;
private int _minPoolSize = DbConnectionStringDefaults.MinPoolSize;
private int _packetSize = DbConnectionStringDefaults.PacketSize;
@@ -155,6 +157,7 @@ private static string[] CreateValidKeywords()
validKeywords[(int)Keywords.InitialCatalog] = DbConnectionStringKeywords.InitialCatalog;
validKeywords[(int)Keywords.IntegratedSecurity] = DbConnectionStringKeywords.IntegratedSecurity;
validKeywords[(int)Keywords.LoadBalanceTimeout] = DbConnectionStringKeywords.LoadBalanceTimeout;
+ validKeywords[(int)Keywords.IdleTimeout] = DbConnectionStringKeywords.IdleTimeout;
validKeywords[(int)Keywords.MaxPoolSize] = DbConnectionStringKeywords.MaxPoolSize;
validKeywords[(int)Keywords.MinPoolSize] = DbConnectionStringKeywords.MinPoolSize;
validKeywords[(int)Keywords.MultipleActiveResultSets] = DbConnectionStringKeywords.MultipleActiveResultSets;
@@ -212,6 +215,7 @@ private static Dictionary CreateKeywordsDictionary()
{ DbConnectionStringKeywords.InitialCatalog, Keywords.InitialCatalog },
{ DbConnectionStringKeywords.IntegratedSecurity, Keywords.IntegratedSecurity },
{ DbConnectionStringKeywords.LoadBalanceTimeout, Keywords.LoadBalanceTimeout },
+ { DbConnectionStringKeywords.IdleTimeout, Keywords.IdleTimeout },
{ DbConnectionStringKeywords.MultipleActiveResultSets, Keywords.MultipleActiveResultSets },
{ DbConnectionStringKeywords.MaxPoolSize, Keywords.MaxPoolSize },
{ DbConnectionStringKeywords.MinPoolSize, Keywords.MinPoolSize },
@@ -349,6 +353,8 @@ private object GetAt(Keywords index)
return IntegratedSecurity;
case Keywords.LoadBalanceTimeout:
return LoadBalanceTimeout;
+ case Keywords.IdleTimeout:
+ return IdleTimeout;
case Keywords.MultipleActiveResultSets:
return MultipleActiveResultSets;
case Keywords.MaxPoolSize:
@@ -481,6 +487,9 @@ private void Reset(Keywords index)
case Keywords.LoadBalanceTimeout:
_loadBalanceTimeout = DbConnectionStringDefaults.LoadBalanceTimeout;
break;
+ case Keywords.IdleTimeout:
+ _idleTimeout = DbConnectionStringDefaults.IdleTimeout;
+ break;
case Keywords.MultipleActiveResultSets:
_multipleActiveResultSets = DbConnectionStringDefaults.MultipleActiveResultSets;
break;
@@ -979,6 +988,9 @@ public override object this[string keyword]
case Keywords.LoadBalanceTimeout:
LoadBalanceTimeout = ConvertToInt32(value);
break;
+ case Keywords.IdleTimeout:
+ IdleTimeout = ConvertToInt32(value);
+ break;
case Keywords.MaxPoolSize:
MaxPoolSize = ConvertToInt32(value);
break;
@@ -1473,6 +1485,25 @@ public int LoadBalanceTimeout
}
}
+ ///
+ [DisplayName(DbConnectionStringKeywords.IdleTimeout)]
+ [ResCategory(nameof(Strings.DataCategory_Pooling))]
+ [ResDescription(nameof(Strings.DbConnectionString_IdleTimeout))]
+ [RefreshProperties(RefreshProperties.All)]
+ public int IdleTimeout
+ {
+ get => _idleTimeout;
+ set
+ {
+ if (value < 0)
+ {
+ throw ADP.InvalidConnectionOptionValue(DbConnectionStringKeywords.IdleTimeout);
+ }
+ SetValue(DbConnectionStringKeywords.IdleTimeout, value);
+ _idleTimeout = value;
+ }
+ }
+
///
[DisplayName(DbConnectionStringKeywords.MaxPoolSize)]
[ResCategory(nameof(Strings.DataCategory_Pooling))]
diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs
index 041b9aa8aa..53697843e6 100644
--- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs
+++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs
@@ -1428,6 +1428,15 @@ internal static string DbConnectionString_LoadBalanceTimeout {
}
}
+ ///
+ /// Looks up a localized string similar to The maximum amount of time (in seconds) a connection can sit unused (idle) in the pool before it is discarded when legacy idle-timeout behavior is disabled. A value of 0 disables idle expiration..
+ ///
+ internal static string DbConnectionString_IdleTimeout {
+ get {
+ return ResourceManager.GetString("DbConnectionString_IdleTimeout", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The maximum number of connections allowed in the pool..
///
diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx
index ca9dcf0a93..335b05df32 100644
--- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx
+++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx
@@ -960,6 +960,9 @@
The minimum amount of time (in seconds) for this connection to live in the pool before being destroyed.
+
+ The maximum amount of time (in seconds) a connection can sit unused (idle) in the pool before it is discarded when legacy idle-timeout behavior is disabled. A value of 0 disables idle expiration.
+
The maximum number of connections allowed in the pool.
diff --git a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs
index 430d6645a8..ce64aea779 100644
--- a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs
+++ b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs
@@ -54,6 +54,7 @@ public sealed class LocalAppContextSwitchesHelper : IDisposable
private readonly bool? _useCompatibilityAsyncBehaviourOriginal;
private readonly bool? _useCompatibilityProcessSniOriginal;
private readonly bool? _useConnectionPoolV2Original;
+ private readonly bool? _useLegacyIdleTimeoutBehaviorOriginal;
private readonly bool? _useOverallConnectTimeoutForPoolWaitOriginal;
#if NET && _WINDOWS
private readonly bool? _useManagedNetworkingOriginal;
@@ -116,6 +117,8 @@ public LocalAppContextSwitchesHelper()
GetSwitchValue("s_useCompatibilityProcessSni");
_useConnectionPoolV2Original =
GetSwitchValue("s_useConnectionPoolV2");
+ _useLegacyIdleTimeoutBehaviorOriginal =
+ GetSwitchValue("s_useLegacyIdleTimeoutBehavior");
_useOverallConnectTimeoutForPoolWaitOriginal =
GetSwitchValue("s_useOverallConnectTimeoutForPoolWait");
#if NET && _WINDOWS
@@ -185,6 +188,9 @@ public void Dispose()
SetSwitchValue(
"s_useConnectionPoolV2",
_useConnectionPoolV2Original);
+ SetSwitchValue(
+ "s_useLegacyIdleTimeoutBehavior",
+ _useLegacyIdleTimeoutBehaviorOriginal);
SetSwitchValue(
"s_useOverallConnectTimeoutForPoolWait",
_useOverallConnectTimeoutForPoolWaitOriginal);
@@ -333,6 +339,15 @@ public bool? UseConnectionPoolV2
set => SetSwitchValue("s_useConnectionPoolV2", value);
}
+ ///
+ /// Get or set the UseLegacyIdleTimeoutBehavior switch value.
+ ///
+ public bool? UseLegacyIdleTimeoutBehavior
+ {
+ get => GetSwitchValue("s_useLegacyIdleTimeoutBehavior");
+ set => SetSwitchValue("s_useLegacyIdleTimeoutBehavior", value);
+ }
+
///
/// Get or set the UseOverallConnectTimeoutForPoolWait switch value.
///
diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs
index a24c330181..180fe07209 100644
--- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs
@@ -250,6 +250,44 @@ public void SetInvalidLoadBalanceTimeout_Throws()
Assert.Contains("load balance timeout", ex.Message, StringComparison.OrdinalIgnoreCase);
}
+ [Fact]
+ public void IdleTimeout_DefaultIs300()
+ {
+ // Default-constructed builder should have IdleTimeout == 300 (5 minutes), matching Npgsql.
+ SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder();
+ Assert.Equal(300, builder.IdleTimeout);
+ }
+
+ [Fact]
+ public void IdleTimeout_RoundTripsThroughConnectionString()
+ {
+ // Set via property, observe in ConnectionString; parse back and observe via property.
+ SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder
+ {
+ IdleTimeout = 45
+ };
+ Assert.Contains("Connection Idle Timeout=45", builder.ConnectionString, StringComparison.OrdinalIgnoreCase);
+
+ SqlConnectionStringBuilder parsed = new SqlConnectionStringBuilder(builder.ConnectionString);
+ Assert.Equal(45, parsed.IdleTimeout);
+ }
+
+ [Fact]
+ public void IdleTimeout_CanonicalKeyword_Parses()
+ {
+ SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder("Connection Idle Timeout=120");
+ Assert.Equal(120, builder.IdleTimeout);
+ }
+
+ [Fact]
+ public void SetInvalidIdleTimeout_Throws()
+ {
+ SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder();
+
+ ArgumentException ex = Assert.Throws(() => builder.IdleTimeout = -1);
+ Assert.Contains("idle", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
[Fact]
public void SetInvalidMaxPoolSize_Throws()
{
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs
index 74d9758cde..3f7f3b0bc5 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs
@@ -35,7 +35,8 @@ private static ChannelDbConnectionPool ConstructPool(
maxPoolSize: maxPoolSize,
creationTimeout: 15,
loadBalanceTimeout: loadBalanceTimeout,
- hasTransactionAffinity: true
+ hasTransactionAffinity: true,
+ idleTimeout: 0
);
var dbConnectionPoolGroup = new DbConnectionPoolGroup(
new SqlConnectionOptions("Data Source=localhost;"),
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs
index 1fea8aede1..b79baefbd5 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs
@@ -12,6 +12,7 @@
using Microsoft.Data.Common.ConnectionString;
using Microsoft.Data.ProviderBase;
using Microsoft.Data.SqlClient.ConnectionPool;
+using Microsoft.Data.SqlClient.Tests.Common;
using Microsoft.Extensions.Time.Testing;
using Xunit;
@@ -34,7 +35,8 @@ private ChannelDbConnectionPool ConstructPool(SqlConnectionFactory connectionFac
maxPoolSize: 50,
creationTimeout: 15,
loadBalanceTimeout: 0,
- hasTransactionAffinity: true
+ hasTransactionAffinity: true,
+ idleTimeout: 0
);
dbConnectionPoolGroup ??= new DbConnectionPoolGroup(
new SqlConnectionOptions("Data Source=localhost;"),
@@ -611,7 +613,8 @@ public void TestLoadBalanceTimeout()
maxPoolSize: 50,
creationTimeout: 15,
loadBalanceTimeout: 500,
- hasTransactionAffinity: true
+ hasTransactionAffinity: true,
+ idleTimeout: 0
);
var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions);
Assert.Equal(poolGroupOptions.LoadBalanceTimeout, pool.LoadBalanceTimeout);
@@ -629,7 +632,8 @@ public void TestPoolGroup()
maxPoolSize: 50,
creationTimeout: 15,
loadBalanceTimeout: 500,
- hasTransactionAffinity: true));
+ hasTransactionAffinity: true,
+ idleTimeout: 0));
var pool = ConstructPool(SuccessfulConnectionFactory, dbConnectionPoolGroup: dbConnectionPoolGroup);
Assert.Equal(dbConnectionPoolGroup, pool.PoolGroup);
}
@@ -643,7 +647,8 @@ public void TestPoolGroupOptions()
maxPoolSize: 50,
creationTimeout: 15,
loadBalanceTimeout: 500,
- hasTransactionAffinity: true);
+ hasTransactionAffinity: true,
+ idleTimeout: 0);
var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions);
Assert.Equal(poolGroupOptions, pool.PoolGroupOptions);
}
@@ -679,7 +684,8 @@ public void TestUseLoadBalancing()
maxPoolSize: 50,
creationTimeout: 15,
loadBalanceTimeout: 500,
- hasTransactionAffinity: true);
+ hasTransactionAffinity: true,
+ idleTimeout: 0);
var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions);
Assert.Equal(poolGroupOptions.UseLoadBalancing, pool.UseLoadBalancing);
}
@@ -938,6 +944,218 @@ out DbConnectionInternal? newConnection
#endregion
+ #region Idle Timeout Tests
+
+ // Helper: build a pool whose IdleTimeout is the given number of seconds.
+ private ChannelDbConnectionPool ConstructPoolWithIdleTimeout(int idleTimeoutSeconds)
+ {
+ var poolGroupOptions = new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 50,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true,
+ idleTimeout: idleTimeoutSeconds);
+ return ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions);
+ }
+
+ [Fact]
+ public void IdleTimeout_PoolGroupOptions_ConvertsSecondsToTimeSpan()
+ {
+ // 30 seconds in -> TimeSpan(0, 0, 30) out.
+ var poolGroupOptions = new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 50,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true,
+ idleTimeout: 30);
+
+ Assert.Equal(TimeSpan.FromSeconds(30), poolGroupOptions.IdleTimeout);
+ }
+
+ [Fact]
+ public void IdleTimeout_StampedOnReturn()
+ {
+ using LocalAppContextSwitchesHelper switchesHelper = new();
+ switchesHelper.UseLegacyIdleTimeoutBehavior = false;
+
+ // Arrange - long idle timeout so the return path stamps (not evicts).
+ var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 3600);
+ SqlConnection owningConnection = new();
+ pool.TryGetConnection(owningConnection, taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? connection);
+ Assert.NotNull(connection);
+
+ // Backdate by a small amount that's still well inside the idle window so the return path
+ // doesn't decide to evict instead of stamp.
+ BackdateReturnedTime(connection, TimeSpan.FromSeconds(5));
+ DateTime stampedBack = connection.ReturnedTime;
+
+ // Act
+ DateTime before = DateTime.UtcNow;
+ pool.ReturnInternalConnection(connection, owningConnection);
+ DateTime after = DateTime.UtcNow;
+
+ // Assert: stamp falls within the return window and is strictly newer than the backdated value.
+ Assert.InRange(connection.ReturnedTime, before, after);
+ Assert.True(connection.ReturnedTime > stampedBack);
+ }
+
+ [Fact]
+ public void IdleTimeout_Zero_DoesNotExpire()
+ {
+ // Arrange - pool with idle expiry disabled
+ var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 0);
+ SqlConnection owner = new();
+ pool.TryGetConnection(owner, taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? first);
+ Assert.NotNull(first);
+
+ // Return + back-date ReturnedTime to simulate a long sit.
+ pool.ReturnInternalConnection(first, owner);
+ BackdateReturnedTime(first, TimeSpan.FromHours(1));
+
+ // Act
+ SqlConnection owner2 = new();
+ pool.TryGetConnection(owner2, taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? second);
+
+ // Assert - same instance, idle expiry disabled
+ Assert.Same(first, second);
+ Assert.Equal(1, pool.Count);
+ }
+
+ [Fact]
+ public void IdleTimeout_Set_ExpiresOldConnection()
+ {
+ using LocalAppContextSwitchesHelper switchesHelper = new();
+ switchesHelper.UseLegacyIdleTimeoutBehavior = false;
+
+ // Arrange - pool with 1-second idle timeout
+ var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 1);
+ SqlConnection owner = new();
+ pool.TryGetConnection(owner, taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? first);
+ Assert.NotNull(first);
+
+ // Return + back-date ReturnedTime beyond the timeout.
+ pool.ReturnInternalConnection(first, owner);
+ BackdateReturnedTime(first, TimeSpan.FromSeconds(5));
+
+ // Act - request another connection
+ SqlConnection owner2 = new();
+ pool.TryGetConnection(owner2, taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? second);
+
+ // Assert - the expired one is discarded; a new one is minted.
+ Assert.NotNull(second);
+ Assert.NotSame(first, second);
+ Assert.Equal(1, pool.Count);
+ }
+
+ [Fact]
+ public void IdleTimeout_Set_KeepsFreshConnection()
+ {
+ using LocalAppContextSwitchesHelper switchesHelper = new();
+ switchesHelper.UseLegacyIdleTimeoutBehavior = false;
+
+ // Arrange - 60-second idle timeout, connection just returned
+ var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 60);
+ SqlConnection owner = new();
+ pool.TryGetConnection(owner, taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? first);
+ Assert.NotNull(first);
+ pool.ReturnInternalConnection(first, owner);
+
+ // Act - immediately request another connection
+ SqlConnection owner2 = new();
+ pool.TryGetConnection(owner2, taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? second);
+
+ // Assert - same instance reused, well within idle window
+ Assert.Same(first, second);
+ }
+
+ [Fact]
+ public void IdleTimeout_LegacySwitch_SuppressesEviction()
+ {
+ using LocalAppContextSwitchesHelper switchesHelper = new();
+ switchesHelper.UseLegacyIdleTimeoutBehavior = true;
+
+ // Arrange - 1-second idle timeout, but legacy switch suppresses the new eviction path.
+ var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 1);
+ SqlConnection owner = new();
+ pool.TryGetConnection(owner, taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? first);
+ Assert.NotNull(first);
+
+ // Return + back-date well past the configured timeout.
+ pool.ReturnInternalConnection(first, owner);
+ BackdateReturnedTime(first, TimeSpan.FromMinutes(5));
+
+ // Act - request another connection.
+ SqlConnection owner2 = new();
+ pool.TryGetConnection(owner2, taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? second);
+
+ // Assert - with the legacy switch on, the stale connection is still reused.
+ Assert.Same(first, second);
+ }
+
+ [Fact]
+ public void IdleTimeout_LongCheckout_ReturnedConnectionIsPreserved()
+ {
+ using LocalAppContextSwitchesHelper switchesHelper = new();
+ switchesHelper.UseLegacyIdleTimeoutBehavior = false;
+
+ // 1-second idle timeout: tight enough that a 5-second "checkout" backdate exceeds it.
+ var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 1);
+ SqlConnection owner = new();
+ pool.TryGetConnection(owner, taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? connection);
+ Assert.NotNull(connection);
+
+ // Simulate a long-running query: ReturnedTime (initialised to CreateTime when minted) is
+ // backdated past the idle window while the connection is still checked out and busy on
+ // the wire.
+ BackdateReturnedTime(connection, TimeSpan.FromSeconds(5));
+
+ // Act - return the actively-used connection.
+ pool.ReturnInternalConnection(connection, owner);
+
+ // Assert - the connection must be re-stamped and preserved, not evicted as idle-expired.
+ // If IsLiveConnection runs before SetReturnedTime in ReturnInternalConnection, it sees
+ // the stale stamp and wrongly destroys a healthy in-use connection.
+ Assert.Equal(1, pool.Count);
+
+ SqlConnection owner2 = new();
+ pool.TryGetConnection(owner2, taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? reused);
+ Assert.Same(connection, reused);
+ }
+
+ // Forcibly rewinds a connection's ReturnedTime by the given amount so tests don't have to sleep.
+ private static void BackdateReturnedTime(DbConnectionInternal connection, TimeSpan delta)
+ {
+ connection.ReturnedTime = DateTime.UtcNow - delta;
+ }
+
+ #endregion
+
#region Test classes
internal class SuccessfulSqlConnectionFactory : SqlConnectionFactory
{
@@ -1013,7 +1231,8 @@ public void Constructor_WithZeroMaxPoolSize_ThrowsArgumentOutOfRangeException()
maxPoolSize: 0, // This should cause an exception
creationTimeout: 15,
loadBalanceTimeout: 0,
- hasTransactionAffinity: true
+ hasTransactionAffinity: true,
+ idleTimeout: 0
);
var dbConnectionPoolGroup = new DbConnectionPoolGroup(
new SqlConnectionOptions("Data Source=localhost;"),
@@ -1044,7 +1263,8 @@ public void Constructor_WithLargeMaxPoolSize()
maxPoolSize: 10000,
creationTimeout: 15,
loadBalanceTimeout: 0,
- hasTransactionAffinity: true
+ hasTransactionAffinity: true,
+ idleTimeout: 0
);
var dbConnectionPoolGroup = new DbConnectionPoolGroup(
new SqlConnectionOptions("Data Source=localhost;"),
@@ -1085,7 +1305,8 @@ public void Constructor_WithValidSmallPoolSizes_WorksCorrectly()
maxPoolSize: 1,
creationTimeout: 15,
loadBalanceTimeout: 0,
- hasTransactionAffinity: true
+ hasTransactionAffinity: true,
+ idleTimeout: 0
);
var dbConnectionPoolGroup1 = new DbConnectionPoolGroup(
new SqlConnectionOptions("Data Source=localhost;"),
@@ -1111,7 +1332,8 @@ public void Constructor_WithValidSmallPoolSizes_WorksCorrectly()
maxPoolSize: 2,
creationTimeout: 15,
loadBalanceTimeout: 0,
- hasTransactionAffinity: true
+ hasTransactionAffinity: true,
+ idleTimeout: 0
);
var dbConnectionPoolGroup2 = new DbConnectionPoolGroup(
new SqlConnectionOptions("Data Source=localhost;"),
@@ -1154,7 +1376,8 @@ public async Task ConcurrentCallers_ShouldTimeoutIndependently()
maxPoolSize: 1,
creationTimeout: 15,
loadBalanceTimeout: 0,
- hasTransactionAffinity: true
+ hasTransactionAffinity: true,
+ idleTimeout: 0
);
var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions);
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolBudgetTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolBudgetTest.cs
index 6c00f8e38e..3688c8d772 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolBudgetTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolBudgetTest.cs
@@ -47,7 +47,8 @@ private WaitHandleDbConnectionPool CreatePool(
maxPoolSize: maxPoolSize,
creationTimeout: creationTimeoutMs,
loadBalanceTimeout: 0,
- hasTransactionAffinity: true);
+ hasTransactionAffinity: true,
+ idleTimeout: 0);
var dbConnectionPoolGroup = new DbConnectionPoolGroup(
new SqlConnectionOptions("Data Source=localhost;"),
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs
new file mode 100644
index 0000000000..4cbc8db636
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs
@@ -0,0 +1,234 @@
+// 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.Reflection;
+using Microsoft.Data.Common.ConnectionString;
+using Microsoft.Data.ProviderBase;
+using Microsoft.Data.SqlClient.ConnectionPool;
+using Microsoft.Data.SqlClient.Tests.Common;
+using Xunit;
+
+namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool;
+
+///
+/// Deterministic tests for WaitHandleDbConnectionPool idle-timeout enforcement.
+/// Mirrors the corresponding tests in so that the
+/// retrieval-side idle-expiry behavior is covered for both pool implementations.
+///
+public class WaitHandleDbConnectionPoolIdleTimeoutTest : IDisposable
+{
+ private const int DefaultMaxPoolSize = 50;
+ private const int DefaultMinPoolSize = 0;
+ private const int DefaultCreationTimeoutInMilliseconds = 15000;
+
+ private WaitHandleDbConnectionPool _pool = null!;
+
+ public void Dispose()
+ {
+ _pool?.Shutdown();
+ _pool?.Clear();
+ }
+
+ private WaitHandleDbConnectionPool CreatePool(int idleTimeoutSeconds)
+ {
+ var poolGroupOptions = new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: DefaultMinPoolSize,
+ maxPoolSize: DefaultMaxPoolSize,
+ creationTimeout: DefaultCreationTimeoutInMilliseconds,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true,
+ idleTimeout: idleTimeoutSeconds);
+
+ var dbConnectionPoolGroup = new DbConnectionPoolGroup(
+ new SqlConnectionOptions("Data Source=localhost;"),
+ new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null),
+ poolGroupOptions);
+
+ var pool = new WaitHandleDbConnectionPool(
+ new WaitHandleDbConnectionPoolTransactionTest.MockSqlConnectionFactory(),
+ dbConnectionPoolGroup,
+ DbConnectionPoolIdentity.NoIdentity,
+ new DbConnectionPoolProviderInfo());
+
+ pool.Startup();
+ return pool;
+ }
+
+ private DbConnectionInternal GetConnection(SqlConnection owner)
+ {
+ _pool.TryGetConnection(
+ owner,
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? connection);
+ Assert.NotNull(connection);
+ return connection!;
+ }
+
+ // Forcibly rewinds a connection's ReturnedTime by the given amount so tests don't have to sleep.
+ private static void BackdateReturnedTime(DbConnectionInternal connection, TimeSpan delta)
+ {
+ connection.ReturnedTime = DateTime.UtcNow - delta;
+ }
+
+ [Fact]
+ public void IdleTimeout_StampedOnReturn()
+ {
+ using LocalAppContextSwitchesHelper switchesHelper = new();
+ switchesHelper.UseLegacyIdleTimeoutBehavior = false;
+
+ // Arrange - long idle timeout so the return path stamps (not evicts).
+ _pool = CreatePool(idleTimeoutSeconds: 3600);
+ SqlConnection owner = new();
+ DbConnectionInternal connection = GetConnection(owner);
+
+ // Backdate by a small amount that's still well inside the idle window so the return path
+ // doesn't decide to evict instead of stamp.
+ BackdateReturnedTime(connection, TimeSpan.FromSeconds(5));
+ DateTime stampedBack = connection.ReturnedTime;
+
+ // Act
+ DateTime before = DateTime.UtcNow;
+ _pool.ReturnInternalConnection(connection, owner);
+ DateTime after = DateTime.UtcNow;
+
+ // Assert: stamp falls within the return window and is strictly newer than the backdated value.
+ Assert.InRange(connection.ReturnedTime, before, after);
+ Assert.True(connection.ReturnedTime > stampedBack);
+ }
+
+ [Fact]
+ public void IdleTimeout_Zero_DoesNotExpire()
+ {
+ // Arrange - pool with idle expiry disabled
+ _pool = CreatePool(idleTimeoutSeconds: 0);
+ SqlConnection owner = new();
+ DbConnectionInternal first = GetConnection(owner);
+
+ // Return + back-date ReturnedTime to simulate a long sit.
+ _pool.ReturnInternalConnection(first, owner);
+ BackdateReturnedTime(first, TimeSpan.FromHours(1));
+
+ // Act
+ SqlConnection owner2 = new();
+ DbConnectionInternal second = GetConnection(owner2);
+
+ // Assert - same instance, idle expiry disabled
+ Assert.Same(first, second);
+ Assert.Equal(1, _pool.Count);
+ }
+
+ [Fact]
+ public void IdleTimeout_Set_ExpiresOldConnection()
+ {
+ using LocalAppContextSwitchesHelper switchesHelper = new();
+ switchesHelper.UseLegacyIdleTimeoutBehavior = false;
+
+ // Arrange - pool with 1-second idle timeout
+ _pool = CreatePool(idleTimeoutSeconds: 1);
+ SqlConnection owner = new();
+ DbConnectionInternal first = GetConnection(owner);
+
+ // Return + back-date ReturnedTime beyond the timeout.
+ _pool.ReturnInternalConnection(first, owner);
+ BackdateReturnedTime(first, TimeSpan.FromSeconds(5));
+
+ // Act - request another connection
+ SqlConnection owner2 = new();
+ DbConnectionInternal second = GetConnection(owner2);
+
+ // Assert - the expired one is discarded; a new one is minted.
+ Assert.NotSame(first, second);
+ }
+
+ [Fact]
+ public void IdleTimeout_Set_KeepsFreshConnection()
+ {
+ using LocalAppContextSwitchesHelper switchesHelper = new();
+ switchesHelper.UseLegacyIdleTimeoutBehavior = false;
+
+ // Arrange - 60-second idle timeout, connection just returned
+ _pool = CreatePool(idleTimeoutSeconds: 60);
+ SqlConnection owner = new();
+ DbConnectionInternal first = GetConnection(owner);
+ _pool.ReturnInternalConnection(first, owner);
+
+ // Act - immediately request another connection
+ SqlConnection owner2 = new();
+ DbConnectionInternal second = GetConnection(owner2);
+
+ // Assert - same instance reused, well within idle window
+ Assert.Same(first, second);
+ }
+
+ [Fact]
+ public void IdleTimeout_LegacySwitch_SuppressesEviction()
+ {
+ using LocalAppContextSwitchesHelper switchesHelper = new();
+ switchesHelper.UseLegacyIdleTimeoutBehavior = true;
+
+ // Arrange - 1-second idle timeout, but legacy switch suppresses the new eviction path.
+ _pool = CreatePool(idleTimeoutSeconds: 1);
+ SqlConnection owner = new();
+ DbConnectionInternal first = GetConnection(owner);
+
+ // Return + back-date well past the configured timeout.
+ _pool.ReturnInternalConnection(first, owner);
+ BackdateReturnedTime(first, TimeSpan.FromMinutes(5));
+
+ // Act - request another connection.
+ SqlConnection owner2 = new();
+ DbConnectionInternal second = GetConnection(owner2);
+
+ // Assert - with the legacy switch on, the stale connection is still reused.
+ Assert.Same(first, second);
+ }
+
+ [Fact]
+ public void IdleTimeout_LegacyOff_Zero_CleanupCallbackDoesNotEvict()
+ {
+ using LocalAppContextSwitchesHelper switchesHelper = new();
+ switchesHelper.UseLegacyIdleTimeoutBehavior = false;
+
+ // Arrange - new idle-timeout behavior enabled with IdleTimeout=0 means "disabled entirely":
+ // the generational destroy/age-into-old-stack sweep in CleanupCallback must be a no-op so
+ // connections above MinPoolSize are not pruned on the cleanup tick.
+ _pool = CreatePool(idleTimeoutSeconds: 0);
+
+ // Vend, then return, several connections so they sit in _stackNew.
+ SqlConnection o1 = new();
+ SqlConnection o2 = new();
+ SqlConnection o3 = new();
+ DbConnectionInternal c1 = GetConnection(o1);
+ DbConnectionInternal c2 = GetConnection(o2);
+ DbConnectionInternal c3 = GetConnection(o3);
+ _pool.ReturnInternalConnection(c1, o1);
+ _pool.ReturnInternalConnection(c2, o2);
+ _pool.ReturnInternalConnection(c3, o3);
+
+ int countBefore = _pool.Count;
+ int idleBefore = _pool.IdleCount;
+ Assert.Equal(3, countBefore);
+ Assert.Equal(3, idleBefore);
+
+ // Act - two cleanup cycles. Pre-fix this would move new->old then destroy old above
+ // MinPoolSize (default 0), dropping Count and IdleCount to 0.
+ InvokeCleanupCallback(_pool);
+ InvokeCleanupCallback(_pool);
+
+ // Assert - cleanup loops short-circuited; all three connections still pooled.
+ Assert.Equal(countBefore, _pool.Count);
+ Assert.Equal(idleBefore, _pool.IdleCount);
+ }
+
+ private static void InvokeCleanupCallback(WaitHandleDbConnectionPool pool)
+ {
+ MethodInfo cleanup = typeof(WaitHandleDbConnectionPool).GetMethod(
+ "CleanupCallback",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+ cleanup.Invoke(pool, new object?[] { null });
+ }
+}
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolTransactionTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolTransactionTest.cs
index 555de74b29..88af13922b 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolTransactionTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolTransactionTest.cs
@@ -55,7 +55,8 @@ private WaitHandleDbConnectionPool CreatePool(
maxPoolSize: maxPoolSize,
creationTimeout: DefaultCreationTimeoutInMilliseconds,
loadBalanceTimeout: 0,
- hasTransactionAffinity: hasTransactionAffinity
+ hasTransactionAffinity: hasTransactionAffinity,
+ idleTimeout: 0
);
var dbConnectionPoolGroup = new DbConnectionPoolGroup(
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs
index c5c6f7ec73..67837e10f2 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs
@@ -56,6 +56,7 @@ public void TestDefaultAppContextSwitchValues()
Assert.True(LocalAppContextSwitches.LegacyVarTimeZeroScaleBehaviour);
Assert.True(LocalAppContextSwitches.UseCompatibilityProcessSni);
Assert.True(LocalAppContextSwitches.UseCompatibilityAsyncBehaviour);
+ Assert.True(LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior);
Assert.False(LocalAppContextSwitches.UseConnectionPoolV2);
Assert.False(LocalAppContextSwitches.UseOverallConnectTimeoutForPoolWait);
Assert.False(LocalAppContextSwitches.TruncateScaledDecimal);