Skip to content

Commit 08b0cda

Browse files
committed
fix: release workspace SQLite pools when hosts shut down
1 parent f597334 commit 08b0cda

5 files changed

Lines changed: 108 additions & 16 deletions

File tree

‎docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,4 +438,5 @@ No branches, commits, PRs, issues, or production releases should be created as p
438438
- Semantic loads now read evaluated test flags and restore paths, retain installed SDK generators, expose excluded custom-generator limitations, and reject unverified rename. Rename compares compiler errors by code and mapped location. Approval waits release the global cache gate while concurrent calls share one approved load; failed loads always dispose their build host, and unrelated inaccessible directories are skipped.
439439
- Verification now serializes through the configured lock deadline, runs multi-targeted project paths once, classifies prerequisite errors by diagnostic code, preserves cached build diagnostics across test-only/prerequisite failures, and supports a validated configurable automatic timeout (600 seconds by default). Empty valid TRX results are explicitly skipped. Release package smoke accepts and checks the exact prepacked release artifacts.
440440
- Local follow-up validation: warning-free Release solution/example build; 414 passing tests (280 unit, 114 integration, 20 parity); coverage above the existing 38 percent floor; all four agent scenarios and their gates; DocFX with warnings as errors; all 22 exact prepacked artifacts installed successfully through an isolated clean SDK consumer and CLI tool smoke. Cross-platform PR and merged-head checks remain the remote merge gates.
441+
- Windows CI follow-up: knowledge and usage stores release their own idle SQLite pools on host disposal, allowing database files to be removed on Windows. A host lifecycle regression checks exclusive database access after shutdown. Cancellation parity now observes startup failures, always cancels/drains its task, and uses the existing longer Windows startup allowance. The 23 focused lifecycle/HTTP/verification tests and all 20 parity cases pass locally before the next cross-platform CI run.
441442
- Release acceptance still pending externally: win-x64, win-arm64, linux-x64, linux-arm64 and osx-x64 must execute their configured matching CI jobs. No cross-publish is counted as runtime acceptance, and no packages, tag, commit, PR or GitHub release were published by this implementation run.

‎src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Globalization;
22
using System.Data.Common;
3+
using System.Collections.Concurrent;
34
using Microsoft.Data.Sqlite;
45
using SharpClaw.Code.Infrastructure.Abstractions;
56
using SharpClaw.Code.Memory.Abstractions;
@@ -14,8 +15,9 @@ namespace SharpClaw.Code.Memory.Services;
1415
public sealed class SqliteWorkspaceKnowledgeStore(
1516
IFileSystem fileSystem,
1617
IPathService pathService,
17-
IRuntimeStoragePathResolver storagePathResolver) : IWorkspaceKnowledgeStore
18+
IRuntimeStoragePathResolver storagePathResolver) : IWorkspaceKnowledgeStore, IDisposable
1819
{
20+
private readonly ConcurrentDictionary<string, byte> connectionPools = new(StringComparer.Ordinal);
1921
private const string WorkspaceKnowledgeDirectoryName = "knowledge";
2022
private const string WorkspaceKnowledgeFileName = "knowledge.db";
2123
private const string UserMemoryFileName = "user-memory.db";
@@ -464,15 +466,32 @@ private async Task<SqliteConnection> OpenConnectionAsync(string dbPath, Cancella
464466
fileSystem.CreateDirectory(directory);
465467
}
466468

467-
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
469+
var connectionString = new SqliteConnectionStringBuilder
468470
{
469471
DataSource = dbPath,
470472
Mode = SqliteOpenMode.ReadWriteCreate,
471473
Cache = SqliteCacheMode.Shared,
472-
}.ToString());
473-
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
474-
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
475-
return connection;
474+
}.ToString();
475+
connectionPools.TryAdd(connectionString, 0);
476+
var connection = new SqliteConnection(connectionString);
477+
try
478+
{
479+
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
480+
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
481+
return connection;
482+
}
483+
catch { await connection.DisposeAsync().ConfigureAwait(false); throw; }
484+
}
485+
486+
/// <summary>Releases this store's idle database handles when its host shuts down, including on Windows.</summary>
487+
public void Dispose()
488+
{
489+
foreach (var connectionString in connectionPools.Keys)
490+
{
491+
using var connection = new SqliteConnection(connectionString);
492+
SqliteConnection.ClearPool(connection);
493+
}
494+
connectionPools.Clear();
476495
}
477496

478497
private static async Task EnsureSchemaAsync(SqliteConnection connection, CancellationToken cancellationToken)

‎src/SharpClaw.Code.Runtime/Server/SqliteUsageMeteringStore.cs‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Globalization;
2+
using System.Collections.Concurrent;
23
using Microsoft.Data.Sqlite;
34
using SharpClaw.Code.Infrastructure.Abstractions;
45
using SharpClaw.Code.Protocol.Models;
@@ -12,8 +13,9 @@ namespace SharpClaw.Code.Runtime.Server;
1213
public sealed class SqliteUsageMeteringStore(
1314
IFileSystem fileSystem,
1415
IPathService pathService,
15-
IRuntimeStoragePathResolver storagePathResolver) : IUsageMeteringStore
16+
IRuntimeStoragePathResolver storagePathResolver) : IUsageMeteringStore, IDisposable
1617
{
18+
private readonly ConcurrentDictionary<string, byte> connectionPools = new(StringComparer.Ordinal);
1719
/// <inheritdoc />
1820
public async Task AppendAsync(string workspaceRoot, UsageMeteringRecord record, CancellationToken cancellationToken)
1921
{
@@ -138,10 +140,27 @@ private async Task<SqliteConnection> OpenConnectionAsync(string workspaceRoot, C
138140
?? storagePathResolver.GetTelemetryRoot(normalizedWorkspace);
139141
fileSystem.CreateDirectory(telemetryRoot);
140142

141-
var connection = new SqliteConnection($"Data Source={dbPath}");
142-
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
143-
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
144-
return connection;
143+
var connectionString = new SqliteConnectionStringBuilder { DataSource = dbPath }.ToString();
144+
connectionPools.TryAdd(connectionString, 0);
145+
var connection = new SqliteConnection(connectionString);
146+
try
147+
{
148+
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
149+
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
150+
return connection;
151+
}
152+
catch { await connection.DisposeAsync().ConfigureAwait(false); throw; }
153+
}
154+
155+
/// <summary>Releases this host's idle metering database handles without clearing unrelated workspace pools.</summary>
156+
public void Dispose()
157+
{
158+
foreach (var connectionString in connectionPools.Keys)
159+
{
160+
using var connection = new SqliteConnection(connectionString);
161+
SqliteConnection.ClearPool(connection);
162+
}
163+
connectionPools.Clear();
145164
}
146165

147166
private static async Task EnsureSchemaAsync(SqliteConnection connection, CancellationToken cancellationToken)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using FluentAssertions;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using SharpClaw.Code.Memory.Abstractions;
4+
using SharpClaw.Code.Runtime;
5+
using SharpClaw.Code.Telemetry.Abstractions;
6+
7+
namespace SharpClaw.Code.IntegrationTests.Runtime;
8+
9+
/// <summary>Verifies database handles follow the host lifecycle, including Windows file sharing rules.</summary>
10+
public sealed class WorkspaceDatabaseLifecycleTests
11+
{
12+
/// <summary>A disposed host releases its knowledge and metering databases for exclusive access and workspace removal.</summary>
13+
[Fact]
14+
public async Task Host_disposal_releases_knowledge_and_usage_database_handles()
15+
{
16+
var root = Path.Combine(Path.GetTempPath(), "sharpclaw-db-lifecycle-" + Guid.NewGuid().ToString("N"));
17+
Directory.CreateDirectory(root);
18+
try
19+
{
20+
await File.WriteAllTextAsync(Path.Combine(root, "Source.cs"), "public class Source { }");
21+
using (var provider = new ServiceCollection().AddSharpClawRuntime().BuildServiceProvider())
22+
{
23+
await provider.GetRequiredService<IWorkspaceIndexService>().RefreshAsync(root, CancellationToken.None);
24+
await provider.GetRequiredService<IUsageMeteringStore>().GetSummaryAsync(root, new(), CancellationToken.None);
25+
}
26+
var databases = Directory.GetFiles(root, "*.db", SearchOption.AllDirectories);
27+
databases.Should().HaveCount(2);
28+
foreach (var database in databases)
29+
{
30+
using var exclusive = new FileStream(database, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
31+
exclusive.Length.Should().BeGreaterThan(0);
32+
}
33+
Directory.Delete(root, recursive: true);
34+
Directory.Exists(root).Should().BeFalse();
35+
}
36+
finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); }
37+
}
38+
}

‎tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -327,16 +327,31 @@ private async Task RunPromptWithCancelAfterTurnStartAsync(
327327
{
328328
using var cts = new CancellationTokenSource();
329329
var runTask = runtime.RunPromptAsync(request, cts.Token);
330-
await WaitForActiveTurnAsync(store, CancellationToken.None);
331-
cts.CancelAfter(cancelAfter);
332-
await runTask.ConfigureAwait(false);
330+
try
331+
{
332+
await WaitForActiveTurnAsync(store, runTask, CancellationToken.None);
333+
cts.CancelAfter(cancelAfter);
334+
await runTask.ConfigureAwait(false);
335+
}
336+
finally
337+
{
338+
cts.Cancel();
339+
try { await runTask.ConfigureAwait(false); }
340+
catch (OperationCanceledException) { }
341+
}
333342
}
334343

335-
private async Task WaitForActiveTurnAsync(ISessionStore store, CancellationToken cancellationToken)
344+
private async Task WaitForActiveTurnAsync(ISessionStore store, Task runTask, CancellationToken cancellationToken)
336345
{
337346
// Startup is separate from the cancellation deadline and can be slow on loaded Windows runners.
338-
for (var attempt = 0; attempt < 600; attempt++)
347+
var maxAttempts = OperatingSystem.IsWindows() ? 1800 : 600;
348+
for (var attempt = 0; attempt < maxAttempts; attempt++)
339349
{
350+
if (runTask.IsCompleted)
351+
{
352+
await runTask.ConfigureAwait(false);
353+
throw new InvalidOperationException("The parity runtime completed before an active turn could be observed.");
354+
}
340355
var session = await store.GetLatestAsync(_workspace, cancellationToken).ConfigureAwait(false);
341356
if (!string.IsNullOrWhiteSpace(session?.ActiveTurnId))
342357
{

0 commit comments

Comments
 (0)