diff --git a/changelog.d/unreleased/1668.fixed.md b/changelog.d/unreleased/1668.fixed.md new file mode 100644 index 0000000000..fa172e9c1c --- /dev/null +++ b/changelog.d/unreleased/1668.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1668 +affected: + - tests/CodeIndex.Tests/SqlitePoolSensitiveCollection.cs + - tests/CodeIndex.Tests/SqlitePoolCleanup.cs + - tests/CodeIndex.Tests/SqlitePoolSensitiveCollectionTests.cs +--- + +## English + +- **SQLite pool-sensitive tests now clear pooled handles at collection boundaries (#1668)** — the serialized xUnit collection now owns a cleanup fixture that clears SQLite pools before and after the collection, reducing stale pooled-handle leakage after failed tests. + +## 日本語 + +- **SQLite pool sensitive テストが collection 境界で pooled handle を解放するようになりました (#1668)** — 直列化された xUnit collection が cleanup fixture を持ち、collection の前後で SQLite pool を clear するため、失敗したテスト後に stale な pooled handle が残るリスクを減らします。 diff --git a/tests/CodeIndex.Tests/SqlitePoolCleanup.cs b/tests/CodeIndex.Tests/SqlitePoolCleanup.cs index a2a6ebd345..5c95622165 100644 --- a/tests/CodeIndex.Tests/SqlitePoolCleanup.cs +++ b/tests/CodeIndex.Tests/SqlitePoolCleanup.cs @@ -5,6 +5,7 @@ namespace CodeIndex.Tests; internal static class SqlitePoolCleanup { private static readonly object Gate = new(); + private static Action _clearAllPools = SqliteConnection.ClearAllPools; private static int _activeExclusiveOwners; private static bool _clearPending; @@ -23,15 +24,38 @@ internal static void ClearPoolsForWindowsFileRelease(bool callerOwnsExclusiveAcc if (!OperatingSystem.IsWindows()) return; + ClearPools(callerOwnsExclusiveAccess, deferForActiveOwners: true); + } + + internal static void ClearPoolsAtCollectionBoundary() + { + ClearPools(callerOwnsExclusiveAccess: true, deferForActiveOwners: false); + } + + internal static IDisposable ReplaceClearAllPoolsForTesting(Action clearAllPools) + { + ArgumentNullException.ThrowIfNull(clearAllPools); + lock (Gate) { - if (_activeExclusiveOwners > 0 && !callerOwnsExclusiveAccess) + var prior = _clearAllPools; + _clearAllPools = clearAllPools; + return new RestoreClearAllPools(prior); + } + } + + private static void ClearPools(bool callerOwnsExclusiveAccess, bool deferForActiveOwners) + { + lock (Gate) + { + if (deferForActiveOwners && _activeExclusiveOwners > 0 && !callerOwnsExclusiveAccess) { _clearPending = true; return; } - SqliteConnection.ClearAllPools(); + _clearPending = false; + _clearAllPools(); } } @@ -51,9 +75,27 @@ public void Dispose() if (_activeExclusiveOwners == 0 && _clearPending) { _clearPending = false; - SqliteConnection.ClearAllPools(); + _clearAllPools(); } } } } + + private sealed class RestoreClearAllPools(Action prior) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + lock (Gate) + { + if (_disposed) + return; + + _disposed = true; + _clearAllPools = prior; + _clearPending = false; + } + } + } } diff --git a/tests/CodeIndex.Tests/SqlitePoolSensitiveCollection.cs b/tests/CodeIndex.Tests/SqlitePoolSensitiveCollection.cs index b10a828bda..7f1d08ad8c 100644 --- a/tests/CodeIndex.Tests/SqlitePoolSensitiveCollection.cs +++ b/tests/CodeIndex.Tests/SqlitePoolSensitiveCollection.cs @@ -1,6 +1,21 @@ namespace CodeIndex.Tests; [CollectionDefinition("SQLite pool sensitive", DisableParallelization = true)] -public sealed class SqlitePoolSensitiveCollection +public sealed class SqlitePoolSensitiveCollection : ICollectionFixture { } + +public sealed class SqlitePoolSensitiveFixture : IAsyncLifetime +{ + public Task InitializeAsync() + { + SqlitePoolCleanup.ClearPoolsAtCollectionBoundary(); + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + SqlitePoolCleanup.ClearPoolsAtCollectionBoundary(); + return Task.CompletedTask; + } +} diff --git a/tests/CodeIndex.Tests/SqlitePoolSensitiveCollectionTests.cs b/tests/CodeIndex.Tests/SqlitePoolSensitiveCollectionTests.cs new file mode 100644 index 0000000000..c268681570 --- /dev/null +++ b/tests/CodeIndex.Tests/SqlitePoolSensitiveCollectionTests.cs @@ -0,0 +1,40 @@ +namespace CodeIndex.Tests; + +[Collection("SQLite pool sensitive")] +public sealed class SqlitePoolSensitiveCollectionTests +{ + [Fact] + public void Collection_RegistersPoolCleanupFixture() + { + Assert.Contains( + typeof(SqlitePoolSensitiveCollection).GetInterfaces(), + static type => type.IsGenericType && + type.GetGenericTypeDefinition() == typeof(ICollectionFixture<>) && + type.GetGenericArguments()[0] == typeof(SqlitePoolSensitiveFixture)); + } + + [Fact] + public async Task Fixture_ClearsPoolsOnInitializeAndDispose() + { + var clearCount = 0; + using var _ = SqlitePoolCleanup.ReplaceClearAllPoolsForTesting(() => clearCount++); + var fixture = new SqlitePoolSensitiveFixture(); + + await fixture.InitializeAsync(); + await fixture.DisposeAsync(); + + Assert.Equal(2, clearCount); + } + + [Fact] + public void CollectionBoundaryClear_DoesNotDeferBehindActiveExclusiveOwner() + { + var clearCount = 0; + using var _ = SqlitePoolCleanup.ReplaceClearAllPoolsForTesting(() => clearCount++); + using var owner = SqlitePoolCleanup.EnterExclusiveOwner(); + + SqlitePoolCleanup.ClearPoolsAtCollectionBoundary(); + + Assert.Equal(1, clearCount); + } +}