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 298c38b9db..4da7c48020 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
@@ -160,11 +160,14 @@ internal ChannelDbConnectionPool(
_errorState = new BlockingPeriodErrorState(_instanceId, timeProvider: timeProvider);
}
- // Pruning is only useful when the pool can grow beyond MinPoolSize.
- // If min >= max, the pool is fixed-size and pruning would never activate.
- if (MinPoolSize < MaxPoolSize)
- {
- Pruner = new PoolPruner(this, PoolGroupOptions.LoadBalanceTimeout);
+ // Pruning is only useful when the pool can grow beyond MinPoolSize and idle
+ // connections are subject to reclamation. If min >= max the pool is fixed-size so
+ // pruning would never activate; if Connection Idle Timeout is zero, idle connections
+ // are never reclaimed and there is nothing to prune. The pruning window (and thus how
+ // many samples are collected) is derived from IdleTimeout.
+ if (MinPoolSize < MaxPoolSize && PoolGroupOptions.IdleTimeout != TimeSpan.Zero)
+ {
+ Pruner = new PoolPruner(this, PoolGroupOptions.IdleTimeout);
}
State = Running;
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/PoolPruner.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/PoolPruner.cs
index 07899d26e3..9c6621f5f0 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/PoolPruner.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/PoolPruner.cs
@@ -4,6 +4,7 @@
using System;
using System.Threading;
using Microsoft.Data.Common;
+using Microsoft.Data.SqlClient.Internal;
#nullable enable
@@ -14,29 +15,41 @@ namespace Microsoft.Data.SqlClient.ConnectionPool
/// Periodically samples the idle connection count and, once enough samples are collected,
/// computes the median and prunes that many idle connections from the pool.
///
- /// This type is only instantiated when the pool can shrink (MinPoolSize < MaxPoolSize).
- /// When the pool is fixed-size, the pool holds no instance.
+ /// This type is only instantiated when the pool can shrink (MinPoolSize < MaxPoolSize) and
+ /// idle-timeout based reclamation is enabled (Connection Idle Timeout > 0). When the pool is
+ /// fixed-size or idle reclamation is disabled, the pool holds no instance.
///
///
internal sealed class PoolPruner : IDisposable
{
///
- /// Maximum allowed sample buffer size to prevent excessive memory allocation
- /// from very large lifetime values. With a 10-second interval this supports
- /// idle lifetimes up to 3000 seconds (50 minutes).
+ /// Maximum number of idle-count samples collected per pruning window. Bounds the sample
+ /// buffer size and the per-window sort cost regardless of how large the configured idle
+ /// timeout is. Once a window would need more than this many samples at
+ /// , the sampling interval is stretched instead of growing
+ /// the buffer, keeping the sample count bounded (the interval itself is separately capped by
+ /// , so for very large idle timeouts the effective window can
+ /// be shorter than the configured timeout).
+ /// At the floor this supports idle timeouts up to
+ /// MaxSampleSize * MinIntervalSeconds = 3000 seconds (50 minutes) before stretching.
///
internal const int MaxSampleSize = 300;
///
- /// Default sampling interval.
+ /// Minimum (default) sampling interval, in seconds. Acts as a floor so the pruning timer
+ /// never fires more often than this and preserves the Story 1 cadence for short/typical
+ /// idle timeouts (e.g. the default 300s idle timeout yields a 10s interval and 30 samples).
///
- private static readonly TimeSpan DefaultSamplingInterval = TimeSpan.FromSeconds(10);
+ internal const int MinIntervalSeconds = 10;
///
- /// Default lifetime window used for sample size calculation when
- /// LoadBalanceTimeout (Connection Lifetime) is zero.
+ /// Maximum sampling interval, in seconds (1 day). Purely a defensive guard so the derived
+ /// interval handed to stays well under its
+ /// ~49.7-day limit for pathological idle timeouts (the Connection Idle Timeout keyword
+ /// accepts up to seconds). It only engages above ~300-day idle
+ /// timeouts, so it never affects any realistic configuration.
///
- private static readonly TimeSpan DefaultLifetimeWindow = TimeSpan.FromSeconds(300);
+ internal const int MaxIntervalSeconds = 24 * 60 * 60;
///
/// The owning connection pool whose idle connections are pruned.
@@ -56,7 +69,8 @@ internal sealed class PoolPruner : IDisposable
///
/// Number of idle count samples to collect before computing the median and pruning.
- /// Equals lifetime / interval (rounded up), clamped to .
+ /// Equals ceil(idleTimeout / samplingInterval), bounded by
+ /// (the interval is stretched rather than exceeding the sample cap for large idle timeouts).
///
private readonly int _sampleSize;
@@ -88,25 +102,46 @@ internal sealed class PoolPruner : IDisposable
/// Creates a new pruner for the given pool.
///
/// The owning connection pool.
- ///
- /// The connection lifetime window used to compute sample count.
- /// When zero or negative, is used.
+ ///
+ /// The configured Connection Idle Timeout, used as the pruning window. The sampling interval
+ /// and sample count are derived from it so the window covers the idle timeout for typical
+ /// values, while the interval stays bounded by and the sample
+ /// count by (see the constructor body for the exact formula). For
+ /// very large idle timeouts both are capped, so the effective window can be shorter than the
+ /// configured timeout. The pool only constructs a pruner when idle-timeout based reclamation is
+ /// enabled (idleTimeout > 0); a defensive floor keeps the sizing math valid.
///
- internal PoolPruner(ChannelDbConnectionPool pool, TimeSpan lifetimeWindow)
+ internal PoolPruner(ChannelDbConnectionPool pool, TimeSpan idleTimeout)
{
_pool = pool;
- _samplingInterval = DefaultSamplingInterval;
- int lifetimeSeconds = (int)lifetimeWindow.TotalSeconds;
- if (lifetimeSeconds <= 0)
+ // The pool only constructs a pruner when IdleTimeout > 0, but floor at one second so
+ // the sizing math below always sees a positive window even under unexpected inputs.
+ int idleTimeoutSeconds = Math.Max(1, (int)idleTimeout.TotalSeconds);
+
+ // Derive the interval so the window covers the idle timeout with at most MaxSampleSize
+ // samples. Short/typical timeouts keep the MinIntervalSeconds cadence (e.g. 300s -> 10s,
+ // 30 samples); large timeouts stretch the interval and pin the count at MaxSampleSize. The
+ // MaxIntervalSeconds cap is a defensive Timer.Change overflow guard (engages >~300 days).
+ // Math.Clamp is unavailable on net462, so clamp explicitly with Min/Max.
+ int intervalForMaxSamples = DivideRoundingUp(idleTimeoutSeconds, MaxSampleSize);
+ int flooredInterval = Math.Max(intervalForMaxSamples, MinIntervalSeconds);
+ int intervalSeconds = Math.Min(flooredInterval, MaxIntervalSeconds);
+
+ int sampleSize = Math.Min(MaxSampleSize, DivideRoundingUp(idleTimeoutSeconds, intervalSeconds));
+
+ if (intervalSeconds != MinIntervalSeconds)
{
- lifetimeSeconds = (int)DefaultLifetimeWindow.TotalSeconds;
+ // The interval was stretched (or clamped) away from the default. Surface the derived
+ // cadence so operators can tell why a very large Connection Idle Timeout samples less
+ // frequently than the default 10-second interval.
+ SqlClientEventSource.Log.TryPoolerTraceEvent(
+ " Idle timeout {0}s derived a pruning interval of {1}s with {2} samples (max {3}).",
+ idleTimeoutSeconds, intervalSeconds, sampleSize, MaxSampleSize);
}
- int intervalSeconds = (int)_samplingInterval.TotalSeconds;
- _sampleSize = Math.Min(
- DivideRoundingUp(lifetimeSeconds, intervalSeconds),
- MaxSampleSize);
+ _samplingInterval = TimeSpan.FromSeconds(intervalSeconds);
+ _sampleSize = sampleSize;
// Subtract 1 to convert from length to 0-based index. Safe because
// DivideRoundingUp always returns >= 1, so _sampleSize >= 1.
@@ -127,6 +162,9 @@ internal PoolPruner(ChannelDbConnectionPool pool, TimeSpan lifetimeWindow)
/// Total number of samples collected per pruning window. Exposed for unit tests.
internal int SampleSize => _sampleSize;
+ /// The derived interval between pruning samples/evaluations. Exposed for unit tests.
+ internal TimeSpan SamplingInterval => _samplingInterval;
+
/// Read-only view of the sample buffer contents. Exposed for unit tests.
internal ReadOnlySpan Samples => _samples;
@@ -162,7 +200,7 @@ internal void UpdateTimer()
else if (numConnections <= _pool.PoolGroupOptions.MinPoolSize && _timerEnabled)
{
// Pool shrunk back to min — stop pruning, reset sample buffer.
- _timer.Change(Timeout.Infinite, Timeout.Infinite);
+ _timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
_sampleIndex = 0;
_timerEnabled = false;
}
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs
index 3f7f3b0bc5..fad16cf230 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs
@@ -27,7 +27,8 @@ public class ChannelDbConnectionPoolPruningTest
private static ChannelDbConnectionPool ConstructPool(
int minPoolSize = 0,
int maxPoolSize = 50,
- int loadBalanceTimeout = 0)
+ int loadBalanceTimeout = 0,
+ int idleTimeout = 300)
{
var poolGroupOptions = new DbConnectionPoolGroupOptions(
poolByIdentity: false,
@@ -36,7 +37,7 @@ private static ChannelDbConnectionPool ConstructPool(
creationTimeout: 15,
loadBalanceTimeout: loadBalanceTimeout,
hasTransactionAffinity: true,
- idleTimeout: 0
+ idleTimeout: idleTimeout
);
var dbConnectionPoolGroup = new DbConnectionPoolGroup(
new SqlConnectionOptions("Data Source=localhost;"),
@@ -160,6 +161,15 @@ public void Constructor_MinPoolSizeEqualsMax_DoesNotCreatePruningTimer()
Assert.Null(pool.Pruner);
}
+ [Fact]
+ public void Constructor_IdleTimeoutZero_DoesNotCreatePruningTimer()
+ {
+ // Connection Idle Timeout = 0 disables idle reclamation, so there is nothing to prune.
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, idleTimeout: 0);
+
+ Assert.Null(pool.Pruner);
+ }
+
[Fact]
public void Constructor_PruningTimerStartsDisabled()
{
@@ -171,19 +181,28 @@ public void Constructor_PruningTimerStartsDisabled()
}
[Theory]
- [InlineData(100, 10)] // 100 / 10 = 10 samples
- [InlineData(300, 30)] // 300 / 10 = 30 samples
- [InlineData(60, 6)] // 60 / 10 = 6 samples
- [InlineData(15, 2)] // 15 / 10 = 2 samples (rounds up)
- [InlineData(0, 30)] // 0 falls back to DefaultLifetimeWindow (300) / 10 = 30 samples
- [InlineData(10000, 300)] // 10000 / 10 = 1000, clamped to MaxPruningSampleSize (300)
- public void Constructor_CalculatesSampleSizeFromLoadBalanceTimeout(
- int loadBalanceTimeout, int expectedSampleSize)
+ // Short/typical idle timeouts keep the 10s floor and grow the sample count (Story 1 parity).
+ [InlineData(100, 10, 10)] // interval 10s, ceil(100/10) = 10 samples
+ [InlineData(300, 10, 30)] // default idle timeout: 10s, 30 samples
+ [InlineData(60, 10, 6)] // 10s, 6 samples
+ [InlineData(15, 10, 2)] // 10s, ceil(15/10) = 2 samples
+ [InlineData(5, 10, 1)] // sub-interval idle timeout: floored to 10s, 1 sample
+ [InlineData(3000, 10, 300)] // boundary: MaxSampleSize * 10s, still 10s cadence
+ // Large idle timeouts pin the sample count at MaxSampleSize and stretch the interval so the
+ // window still spans the full idle timeout.
+ [InlineData(6000, 20, 300)] // ceil(6000/300) = 20s interval, 300 samples
+ [InlineData(10000, 34, 295)] // ceil(10000/300) = 34s interval, ceil(10000/34) = 295 samples
+ [InlineData(86400, 288, 300)] // 1-day idle timeout: 288s interval, 300 samples
+ // Pathological idle timeout hits the 1-day interval overflow clamp without throwing.
+ [InlineData(int.MaxValue, 86400, 300)]
+ public void Constructor_CalculatesIntervalAndSampleSizeFromIdleTimeout(
+ int idleTimeout, int expectedIntervalSeconds, int expectedSampleSize)
{
- using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: loadBalanceTimeout);
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, idleTimeout: idleTimeout);
var pruner = GetPruner(pool);
Assert.Equal(expectedSampleSize, pruner.SampleSize);
+ Assert.Equal(TimeSpan.FromSeconds(expectedIntervalSeconds), pruner.SamplingInterval);
}
#endregion
@@ -262,9 +281,9 @@ public void UpdatePruningTimer_PoolShrinksBackToMin_DisablesTimerAndResetsSample
[Fact]
public void PruneIdleConnections_BufferNotFull_CollectsSampleWithoutPruning()
{
- using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: 30);
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, idleTimeout: 30);
var pruner = GetPruner(pool);
- // loadBalanceTimeout=30 → sample size = 30/10 = 3
+ // idleTimeout=30 → sample size = 30/10 = 3
// Fill the pool so pruning timer is active
FillPoolWithIdleConnections(pool, 5);
@@ -281,8 +300,8 @@ public void PruneIdleConnections_BufferNotFull_CollectsSampleWithoutPruning()
[Fact]
public void PruneIdleConnections_RespectsMinPoolSizeFloor()
{
- // loadBalanceTimeout=20 → 2 samples
- using var pool = ConstructPool(minPoolSize: 5, maxPoolSize: 20, loadBalanceTimeout: 20);
+ // idleTimeout=20 → 2 samples
+ using var pool = ConstructPool(minPoolSize: 5, maxPoolSize: 20, idleTimeout: 20);
var pruner = GetPruner(pool);
// Fill pool with 10 idle connections
@@ -305,7 +324,7 @@ public void PruneIdleConnections_RespectsMinPoolSizeFloor()
[Fact]
public void PruneIdleConnections_TimerDisabled_ReturnsEarlyWithoutPruning()
{
- using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: 20);
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, idleTimeout: 20);
var pruner = GetPruner(pool);
// Pool starts empty, timer is disabled. Calling prune should be a no-op.
@@ -318,8 +337,8 @@ public void PruneIdleConnections_TimerDisabled_ReturnsEarlyWithoutPruning()
[Fact]
public void PruneIdleConnections_DoesNotRemoveInUseConnections()
{
- // loadBalanceTimeout=20 → 2 samples
- using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, loadBalanceTimeout: 20);
+ // idleTimeout=20 → 2 samples
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, idleTimeout: 20);
var pruner = GetPruner(pool);
// Get 5 connections and KEEP them checked out (in use)
@@ -428,8 +447,8 @@ public void PruneIdleConnections_AllIdleWithZeroMinPoolSize_PrunesEntirePool()
// When: The pruning interval elapses (sample buffer fills).
// Then: The pool closes excess idle connections.
- // Use loadBalanceTimeout=20 for 2 samples (fast buffer fill)
- using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, loadBalanceTimeout: 20);
+ // Use idleTimeout=20 for 2 samples (fast buffer fill)
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, idleTimeout: 20);
var pruner = GetPruner(pool);
// Simulate high load: open 20 connections
@@ -456,8 +475,8 @@ public void PruneIdleConnections_VariedSamples_UsesMedianNotCurrentIdleCount()
// When: The pruning interval elapses.
// Then: Pruning uses sampled usage data (median) to avoid being too aggressive.
- // Use 3 samples (loadBalanceTimeout=30)
- using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, loadBalanceTimeout: 30);
+ // Use 3 samples (idleTimeout=30)
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, idleTimeout: 30);
var pruner = GetPruner(pool);
// Simulate varied usage:
@@ -498,8 +517,8 @@ public void PruneIdleConnections_EvenSampleCountWithDistinctValues_UsesLowerMidd
// Using distinct sample values proves the lower-middle is chosen rather than the
// upper-middle or an average.
- // loadBalanceTimeout=20 → 2 samples
- using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, loadBalanceTimeout: 20);
+ // idleTimeout=20 → 2 samples
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, idleTimeout: 20);
var pruner = GetPruner(pool);
// Start with 8 idle connections.
@@ -535,8 +554,8 @@ public void PruneIdleConnections_SecondWindowAfterPrune_RecomputesMedianFromFres
// the first: it computes its own median from newly recorded samples and prunes the
// correct amount, confirming the collect → prune → reset → collect cycle is repeatable.
- // loadBalanceTimeout=20 → 2 samples per window
- using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, loadBalanceTimeout: 20);
+ // idleTimeout=20 → 2 samples per window
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, idleTimeout: 20);
var pruner = GetPruner(pool);
// --- Window 1: produces a median of 4 ---
@@ -570,6 +589,119 @@ public void PruneIdleConnections_SecondWindowAfterPrune_RecomputesMedianFromFres
Assert.Equal(0, pool.Count);
}
+ [Fact]
+ public void PruneIdleConnections_TransientDemandSpikeWithinWindow_DoesNotUnderPrune()
+ {
+ // Complements VariedSamples (which proves a brief lull doesn't over-prune):
+ // here a brief demand spike (a single low idle sample) must not drag the median down
+ // and cause the pruner to under-prune. The sustained idle level should still be reclaimed.
+
+ // idleTimeout=30 → 3 samples
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, idleTimeout: 30);
+ var pruner = GetPruner(pool);
+
+ FillPoolWithIdleConnections(pool, 10);
+
+ // Sample 1: 10 idle (steady state).
+ pruner.OnPruningCallback(null);
+ AssertPrunerState(pruner, true, 1, 10);
+ Assert.Equal(10, pool.Count);
+
+ // Demand spike: check out 8, leaving only 2 idle for the middle sample.
+ var busyConnections = CheckOutConnections(pool, 8);
+
+ // Sample 2: 2 idle (transient spike).
+ pruner.OnPruningCallback(null);
+ AssertPrunerState(pruner, true, 2, 10, 2);
+ Assert.Equal(10, pool.Count);
+
+ // Spike subsides — all connections idle again.
+ ReturnConnections(pool, busyConnections);
+
+ // Sample 3: 10 idle. Buffer full → sorted=[2,10,10], median at index 1 = 10.
+ int countBefore = pool.Count;
+ pruner.OnPruningCallback(null);
+ // Median prunes all 10 idle down to MinPoolSize (0), so the timer disables.
+ AssertPrunerState(pruner, false, 0);
+
+ // The transient spike (sample of 2) is ignored; the sustained level (10) is pruned.
+ int pruned = countBefore - pool.Count;
+ Assert.Equal(10, pruned);
+ Assert.Equal(0, pool.Count);
+ }
+
+ [Fact]
+ public void Pruning_PruneToMinThenRegrow_ReArmsAndPrunesAgain()
+ {
+ // Exercises the full prune → disable → regrow → re-arm → prune cycle. After a window
+ // prunes the pool down to MinPoolSize the timer disables; opening new connections must
+ // re-arm it, and the next window must sample and prune independently.
+
+ // idleTimeout=20 → 2 samples
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, idleTimeout: 20);
+ var pruner = GetPruner(pool);
+
+ // --- Window 1: prune 6 idle down to min (0), disabling the timer ---
+ FillPoolWithIdleConnections(pool, 6);
+
+ pruner.OnPruningCallback(null); // sample[0] = 6
+ AssertPrunerState(pruner, true, 1, 6);
+
+ pruner.OnPruningCallback(null); // buffer full [6,6], median 6, prune 6 → Count 0
+ AssertPrunerState(pruner, false, 0);
+ Assert.Equal(0, pool.Count);
+
+ // --- Regrow: opening connections must re-arm the disabled timer ---
+ FillPoolWithIdleConnections(pool, 12);
+ AssertPrunerState(pruner, true, 0);
+ Assert.Equal(12, pool.Count);
+
+ // --- Window 2: samples freshly and prunes 12 ---
+ pruner.OnPruningCallback(null); // sample[0] = 12
+ AssertPrunerState(pruner, true, 1, 12);
+
+ int countBefore = pool.Count;
+ pruner.OnPruningCallback(null); // buffer full [12,12], median 12, prune 12 → Count 0
+ AssertPrunerState(pruner, false, 0);
+
+ Assert.Equal(12, countBefore - pool.Count);
+ Assert.Equal(0, pool.Count);
+ }
+
+ [Fact]
+ public void OnPruningCallback_AfterShutdown_IsNoOp()
+ {
+ // A pruning timer callback can race with pool shutdown. Once Shutdown() has disposed the
+ // pruner, a stray callback must be an inert no-op: it must not throw, re-enable the timer,
+ // mutate the sample index, or resurrect connections.
+
+ // idleTimeout=30 → 3 samples
+ using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, idleTimeout: 30);
+ var pruner = GetPruner(pool);
+
+ FillPoolWithIdleConnections(pool, 6);
+
+ // Advance one sample so SampleIndex is non-zero before shutdown.
+ pruner.OnPruningCallback(null);
+ AssertPrunerState(pruner, true, 1, 6);
+
+ pool.Shutdown();
+
+ // Shutdown disposes the pruner (timer disabled) and drains all idle connections.
+ // Dispose intentionally does NOT reset the sample index.
+ Assert.False(pruner.IsTimerEnabled);
+ Assert.Equal(0, pool.Count);
+ int sampleIndexAfterShutdown = pruner.SampleIndex;
+
+ // A stray timer callback after shutdown must be a no-op.
+ Exception recorded = Record.Exception(() => pruner.OnPruningCallback(null));
+
+ Assert.Null(recorded);
+ Assert.False(pruner.IsTimerEnabled);
+ Assert.Equal(sampleIndexAfterShutdown, pruner.SampleIndex);
+ Assert.Equal(0, pool.Count);
+ }
+
#endregion
#region Test classes