From fbfc243e069ceb8d8bfdaf5a52962b15f32d172d Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 9 Jul 2026 22:48:43 +0100 Subject: [PATCH] fix(dotnet): abort in-flight turns before disposing sessions on in-process shutdown Cross-SDK parity with the Node.js fix. Over the in-process (FFI) transport the runtime shares the SDK process, so a turn still running when a session is disposed can leave that session's SQLite session.db handle open. The handle is not reclaimed by terminating a child process (there is none), so on Windows the file stays locked and the session-state temp directory can't be removed. StopAsync() now aborts any in-flight turn before DisposeAsync() for in-process connections, releasing the handle. Scoped to InProcessRuntimeConnection only: stdio/tcp runtimes run in a child process we kill on shutdown, and external servers are shared. Abort+dispose run per-session in a single parallel Task.WhenAll. Re-enables the windows-latest + inprocess leg in dotnet-sdk-tests.yml. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/dotnet-sdk-tests.yml | 4 ---- dotnet/src/Client.cs | 25 ++++++++++++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/dotnet-sdk-tests.yml b/.github/workflows/dotnet-sdk-tests.yml index dcf559228c..909742cdf8 100644 --- a/.github/workflows/dotnet-sdk-tests.yml +++ b/.github/workflows/dotnet-sdk-tests.yml @@ -37,10 +37,6 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] transport: ["default", "inprocess"] - # TODO: Re-enable after fixing in-process sqlite file locking on shutdown on Windows - exclude: - - os: windows-latest - transport: "inprocess" runs-on: ${{ matrix.os }} defaults: run: diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 4da20aea7d..3c101c8ea0 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -445,19 +445,34 @@ async Task StartCoreAsync(CancellationToken ct) /// public async Task StopAsync() { - List errors = []; - - foreach (var session in _sessions.Values.ToArray()) + var disposeErrors = await Task.WhenAll(_sessions.Values.Select(async session => { try { + // TEMPORARY: over the in-process (FFI) transport the runtime shares this + // process, so a turn still running when the runtime disposes the session + // can leave that session's SQLite session.db handle open — it isn't + // reclaimed by terminating a child process, so the file stays locked + // (Windows) and the session-state directory can't be removed. Abort any + // in-flight turn first so it cancels and releases the handle. Aborting a + // session with no active turn is a no-op. Scoped to in-process only: + // stdio/tcp runtimes run in a child process that we kill on shutdown + // (which frees the handle), and for external servers we don't own the + // runtime and aborting would cancel pending work other clients may still + // resume. Remove once the runtime cleans up fully on shutdown. + if (_connection is InProcessRuntimeConnection) + { + await session.AbortAsync(); + } await session.DisposeAsync(); + return (Exception?)null; } catch (Exception ex) { - errors.Add(new IOException($"Failed to dispose session {session.SessionId}: {ex.Message}", ex)); + return new IOException($"Failed to dispose session {session.SessionId}: {ex.Message}", ex); } - } + })); + List errors = [.. disposeErrors.Where(static e => e is not null).Select(static e => e!)]; _sessions.Clear();