diff --git a/src/ClawdDotNet.Core/Backup/BackupService.cs b/src/ClawdDotNet.Core/Backup/BackupService.cs index 64d7c6c..47ff283 100644 --- a/src/ClawdDotNet.Core/Backup/BackupService.cs +++ b/src/ClawdDotNet.Core/Backup/BackupService.cs @@ -157,7 +157,10 @@ public sealed class BackupService var connectionString = new SqliteConnectionStringBuilder { DataSource = sourcePath, - Mode = SqliteOpenMode.ReadOnly + Mode = SqliteOpenMode.ReadOnly, + // Eine einmalige Kopie: Aus dem Pool heraus hielte die Verbindung die Datei + // danach offen, und unter Windows ließe sie sich nicht mehr ersetzen oder löschen. + Pooling = false }.ToString(); using var conn = new SqliteConnection(connectionString); diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs index 712da89..db31519 100644 --- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.Data.Sqlite; namespace ClawdDotNet.Core.Storage; @@ -19,6 +20,9 @@ public sealed class SqliteStorage private readonly string _connectionString; private readonly SemaphoreSlim _writeGate = new(1, 1); + /// Every database opened in this process: full path → connection string. + private static readonly ConcurrentDictionary OpenedDatabases = new(StringComparer.OrdinalIgnoreCase); + public string DatabasePath { get; } public SqliteStorage(string databasePath) @@ -37,6 +41,7 @@ public sealed class SqliteStorage Pooling = true, DefaultTimeout = 30 }.ToString(); + OpenedDatabases[Path.GetFullPath(databasePath)] = _connectionString; Initialize(); @@ -51,6 +56,33 @@ public sealed class SqliteStorage } } + /// + /// Closes the pooled connections of the databases at — a + /// database file, or a directory and everything below it — so the files can be deleted + /// or replaced. + /// + /// Not : that is process-wide and also + /// takes the connections of every other database in use. The tests used it to clean up + /// their temporary databases, and a test running in parallel then lost its connection + /// while opening it — an ObjectDisposedException in ApplyPragmas, one in every + /// few full runs, always in a different test. + /// + public static void ReleasePooledConnections(string path) + { + var full = Path.GetFullPath(path); + var below = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + + foreach (var (database, connectionString) in OpenedDatabases) + { + if (!string.Equals(database, full, StringComparison.OrdinalIgnoreCase) + && !database.StartsWith(below, StringComparison.OrdinalIgnoreCase)) + continue; + + using var connection = new SqliteConnection(connectionString); + SqliteConnection.ClearPool(connection); + } + } + public SqliteConnection OpenConnection() { var connection = new SqliteConnection(_connectionString); diff --git a/tests/ClawdDotNet.App.Tests/Services/TaskAdministrationTests.cs b/tests/ClawdDotNet.App.Tests/Services/TaskAdministrationTests.cs index b192dcf..ba25080 100644 --- a/tests/ClawdDotNet.App.Tests/Services/TaskAdministrationTests.cs +++ b/tests/ClawdDotNet.App.Tests/Services/TaskAdministrationTests.cs @@ -28,7 +28,7 @@ public sealed class TaskAdministrationTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir); try { Directory.Delete(_dir, recursive: true); } catch { /* Aufraeumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Audit/AuditRepositoryTests.cs b/tests/ClawdDotNet.Core.Tests/Audit/AuditRepositoryTests.cs index 629501c..14621ba 100644 --- a/tests/ClawdDotNet.Core.Tests/Audit/AuditRepositoryTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Audit/AuditRepositoryTests.cs @@ -23,7 +23,7 @@ public sealed class AuditRepositoryTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs b/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs index a21e861..57b19cf 100644 --- a/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs @@ -35,7 +35,7 @@ public sealed class BackupServiceTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root); try { Directory.Delete(_root, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } @@ -95,7 +95,7 @@ public sealed class BackupServiceTests : IDisposable }, default); // Verbindungen schließen, damit VACUUM INTO auf eine ruhige Datei trifft. - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_instanceDir); } private string ZipPath => Path.Combine(_root, "sicherung.zip"); diff --git a/tests/ClawdDotNet.Core.Tests/Backup/LicenseBackupTests.cs b/tests/ClawdDotNet.Core.Tests/Backup/LicenseBackupTests.cs index e05d1b4..5da8455 100644 --- a/tests/ClawdDotNet.Core.Tests/Backup/LicenseBackupTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Backup/LicenseBackupTests.cs @@ -40,7 +40,7 @@ public sealed class LicenseBackupTests : IDisposable public void Dispose() { SecretKeyStore.UseDirectory(null); - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root); try { Directory.Delete(_root, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs b/tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs index 5f533f5..c27a1cd 100644 --- a/tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Budget/BudgetGuardTests.cs @@ -25,7 +25,7 @@ public sealed class BudgetGuardTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } @@ -220,7 +220,7 @@ public sealed class UsageRepositoryTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { } } diff --git a/tests/ClawdDotNet.Core.Tests/Memory/MemoryRepositoryTests.cs b/tests/ClawdDotNet.Core.Tests/Memory/MemoryRepositoryTests.cs index e7aede1..3a0ee2d 100644 --- a/tests/ClawdDotNet.Core.Tests/Memory/MemoryRepositoryTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Memory/MemoryRepositoryTests.cs @@ -29,7 +29,7 @@ public sealed class MemoryRepositoryTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Staging/StagingRepositoryTests.cs b/tests/ClawdDotNet.Core.Tests/Staging/StagingRepositoryTests.cs index c8fa26e..2076e99 100644 --- a/tests/ClawdDotNet.Core.Tests/Staging/StagingRepositoryTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Staging/StagingRepositoryTests.cs @@ -23,7 +23,7 @@ public sealed class StagingRepositoryTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Staging/StagingServiceTests.cs b/tests/ClawdDotNet.Core.Tests/Staging/StagingServiceTests.cs index 049dddc..5a1d96f 100644 --- a/tests/ClawdDotNet.Core.Tests/Staging/StagingServiceTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Staging/StagingServiceTests.cs @@ -35,7 +35,7 @@ public sealed class StagingServiceTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Staging/WerkzeugrechteTests.cs b/tests/ClawdDotNet.Core.Tests/Staging/WerkzeugrechteTests.cs index 95eeceb..8365a4e 100644 --- a/tests/ClawdDotNet.Core.Tests/Staging/WerkzeugrechteTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Staging/WerkzeugrechteTests.cs @@ -33,7 +33,7 @@ public sealed class WerkzeugrechteTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Storage/PoolReleaseTests.cs b/tests/ClawdDotNet.Core.Tests/Storage/PoolReleaseTests.cs new file mode 100644 index 0000000..e3f23bc --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Storage/PoolReleaseTests.cs @@ -0,0 +1,61 @@ +using ClawdDotNet.Core.Storage; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Storage; + +/// +/// Releasing the pool of one database must leave every other database alone. The tests used +/// the process-wide ClearAllPools to clean up, and a test running in parallel lost its +/// connection in the middle of opening it — one red run in every few, always another test. +/// +public sealed class PoolReleaseTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "pool-" + Guid.NewGuid().ToString("N")[..8]); + + public void Dispose() + { + SqliteStorage.ReleasePooledConnections(_root); + try { Directory.Delete(_root, recursive: true); } + catch { /* cleaning up is secondary */ } + } + + private static async Task TouchAsync(SqliteStorage storage) + => await storage.WriteAsync(async connection => + { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES ('k', 'v')"; + await cmd.ExecuteNonQueryAsync(); + }, default); + + [Fact] + public async Task Releasing_one_directory_frees_its_files_and_nothing_else() + { + var first = new SqliteStorage(Path.Combine(_root, "a", "state.db")); + var second = new SqliteStorage(Path.Combine(_root, "b", "state.db")); + await TouchAsync(first); + await TouchAsync(second); + + SqliteStorage.ReleasePooledConnections(Path.Combine(_root, "a")); + + // The released database can go — on Windows an open handle would prevent that. + Should.NotThrow(() => Directory.Delete(Path.Combine(_root, "a"), recursive: true)); + + // The other one keeps working, and so does its pool. + await TouchAsync(second); + await using var connection = await second.OpenConnectionAsync(default); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT Value FROM ToolState WHERE Key = 'k'"; + (await cmd.ExecuteScalarAsync()).ShouldBe("v"); + } + + [Fact] + public async Task A_database_file_can_be_named_directly() + { + var path = Path.Combine(_root, "c", "state.db"); + await TouchAsync(new SqliteStorage(path)); + + SqliteStorage.ReleasePooledConnections(path); + + Should.NotThrow(() => File.Delete(path)); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/BudgetMissTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/BudgetMissTests.cs index 44edfa7..6838c9a 100644 --- a/tests/ClawdDotNet.Core.Tests/Tasks/BudgetMissTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Tasks/BudgetMissTests.cs @@ -32,7 +32,7 @@ public sealed class BudgetMissTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/CoordinationMigrationTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/CoordinationMigrationTests.cs index 6289b39..c0c63a8 100644 --- a/tests/ClawdDotNet.Core.Tests/Tasks/CoordinationMigrationTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Tasks/CoordinationMigrationTests.cs @@ -35,7 +35,7 @@ public sealed class CoordinationMigrationTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/ScannerCapacityTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/ScannerCapacityTests.cs index c42b213..ac17e37 100644 --- a/tests/ClawdDotNet.Core.Tests/Tasks/ScannerCapacityTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Tasks/ScannerCapacityTests.cs @@ -30,7 +30,7 @@ public sealed class ScannerCapacityTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/TaskRepositoryTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/TaskRepositoryTests.cs index 5671112..b4c3d75 100644 --- a/tests/ClawdDotNet.Core.Tests/Tasks/TaskRepositoryTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Tasks/TaskRepositoryTests.cs @@ -27,7 +27,7 @@ public sealed class TaskRepositoryTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/TaskScannerTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/TaskScannerTests.cs index 042da8d..d3d7bf7 100644 --- a/tests/ClawdDotNet.Core.Tests/Tasks/TaskScannerTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Tasks/TaskScannerTests.cs @@ -27,7 +27,7 @@ public sealed class TaskScannerTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/TaskboardServiceTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/TaskboardServiceTests.cs index abe883d..a7da9d7 100644 --- a/tests/ClawdDotNet.Core.Tests/Tasks/TaskboardServiceTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Tasks/TaskboardServiceTests.cs @@ -28,7 +28,7 @@ public sealed class TaskboardServiceTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Core.Tests/Transcripts/PromptHistoryTests.cs b/tests/ClawdDotNet.Core.Tests/Transcripts/PromptHistoryTests.cs index 150d8d2..b364725 100644 --- a/tests/ClawdDotNet.Core.Tests/Transcripts/PromptHistoryTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Transcripts/PromptHistoryTests.cs @@ -26,7 +26,7 @@ public sealed class PromptHistoryTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is best effort */ } } diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerBackupsTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerBackupsTests.cs index d9c3678..46632f9 100644 --- a/tests/ClawdDotNet.Manager.Tests/ManagerBackupsTests.cs +++ b/tests/ClawdDotNet.Manager.Tests/ManagerBackupsTests.cs @@ -34,7 +34,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable public async ValueTask DisposeAsync() { await Task.CompletedTask; - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root); try { Directory.Delete(_root, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } diff --git a/tests/ClawdDotNet.Tools.Tests/Memory/MemoryToolTests.cs b/tests/ClawdDotNet.Tools.Tests/Memory/MemoryToolTests.cs index b473236..8a211b5 100644 --- a/tests/ClawdDotNet.Tools.Tests/Memory/MemoryToolTests.cs +++ b/tests/ClawdDotNet.Tools.Tests/Memory/MemoryToolTests.cs @@ -28,7 +28,7 @@ public sealed class MemoryToolTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir); try { Directory.Delete(_dir, recursive: true); } catch { /* Aufraeumen ist Nebensache */ } } diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/AssignmentReachTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/AssignmentReachTests.cs index 02f6162..a7fe20b 100644 --- a/tests/ClawdDotNet.Tools.Tests/Taskboard/AssignmentReachTests.cs +++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/AssignmentReachTests.cs @@ -43,7 +43,7 @@ public sealed class AssignmentReachTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/DublettenTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/DublettenTests.cs index 2c7e548..eac48a4 100644 --- a/tests/ClawdDotNet.Tools.Tests/Taskboard/DublettenTests.cs +++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/DublettenTests.cs @@ -35,7 +35,7 @@ public sealed class DublettenTests : IDisposable public void Dispose() { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_verzeichnis); try { Directory.Delete(_verzeichnis, recursive: true); } catch { /* Aufräumen ist Nebensache */ } }