From 6dde69dc225dc3d8064ea2ef4b920d33cb45a3ca Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 1 Jun 2026 01:09:54 +0900 Subject: [PATCH 1/3] Fix MCP index concurrency guard (#1459) --- changelog.d/unreleased/1459.fixed.md | 17 +++ src/CodeIndex/Mcp/McpIndexRunLock.cs | 149 ++++++++++++++++++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 4 + tests/CodeIndex.Tests/McpServerTests.cs | 51 ++++++++ 4 files changed, 221 insertions(+) create mode 100644 changelog.d/unreleased/1459.fixed.md create mode 100644 src/CodeIndex/Mcp/McpIndexRunLock.cs diff --git a/changelog.d/unreleased/1459.fixed.md b/changelog.d/unreleased/1459.fixed.md new file mode 100644 index 0000000000..1678322f9d --- /dev/null +++ b/changelog.d/unreleased/1459.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1459 +affected: + - src/CodeIndex/Mcp/McpIndexRunLock.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP index now rejects concurrent runs on the same database (#1459)** — the `index` tool acquires an exclusive per-database lock before mutating index state and returns a clear busy error with holder metadata when another run is already active. + +## 日本語 + +- **MCP index が同じデータベースへの同時実行を拒否するようになりました (#1459)** — `index` ツールは index 状態を変更する前にデータベース単位の排他ロックを取得し、別の実行中処理がある場合は保持情報付きの明確な busy error を返します。 diff --git a/src/CodeIndex/Mcp/McpIndexRunLock.cs b/src/CodeIndex/Mcp/McpIndexRunLock.cs new file mode 100644 index 0000000000..28c2563965 --- /dev/null +++ b/src/CodeIndex/Mcp/McpIndexRunLock.cs @@ -0,0 +1,149 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace CodeIndex.Mcp; + +internal sealed class McpIndexRunLock : IDisposable +{ + internal const string LockFileName = "index.lock"; + private static readonly TimeSpan StaleInfoGracePeriod = TimeSpan.FromSeconds(2); + + private readonly FileStream _stream; + private readonly string _infoPath; + private bool _disposed; + + private McpIndexRunLock(FileStream stream, string infoPath) + { + _stream = stream; + _infoPath = infoPath; + } + + internal static bool TryAcquire(string dbPath, out McpIndexRunLock? runLock, out string? error) + { + runLock = null; + error = null; + + var lockPath = ResolveLockPath(dbPath); + var lockDirectory = Path.GetDirectoryName(lockPath); + if (!string.IsNullOrWhiteSpace(lockDirectory)) + Directory.CreateDirectory(lockDirectory); + + var infoPath = lockPath + ".info"; + try + { + var stream = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + var acquired = new McpIndexRunLock(stream, infoPath); + acquired.WriteHolderInfo(); + runLock = acquired; + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + error = BuildBusyMessage(infoPath); + return false; + } + } + + internal static string ResolveLockPath(string dbPath) + { + if (Uri.TryCreate(dbPath, UriKind.Absolute, out var uri) && uri.IsFile) + dbPath = uri.LocalPath; + + var directory = Path.GetDirectoryName(Path.GetFullPath(dbPath)); + if (string.IsNullOrWhiteSpace(directory)) + directory = Path.GetFullPath("."); + + var fileName = Path.GetFileName(dbPath); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "codeindex.db"; + + return Path.Combine(directory, $"{fileName}.{LockFileName}"); + } + + private void WriteHolderInfo() + { + var since = DateTimeOffset.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture); + try + { + File.WriteAllText(_infoPath, $$"""{"pid":{{Environment.ProcessId}},"since":"{{since}}"}"""); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + + private static string BuildBusyMessage(string infoPath) + { + var holder = TryReadHolderInfo(infoPath); + if (holder is { ProcessStillRunning: false } && DateTimeOffset.UtcNow - holder.Since >= StaleInfoGracePeriod) + return $"index already running on this DB (stale lock metadata from pid {holder.Pid} since {holder.Since:o})"; + + if (holder != null) + return $"index already running on this DB (held by pid {holder.Pid} since {holder.Since:o})"; + + return "index already running on this DB (holder metadata unavailable)"; + } + + private static HolderInfo? TryReadHolderInfo(string infoPath) + { + try + { + if (!File.Exists(infoPath)) + return null; + + using var document = JsonDocument.Parse(File.ReadAllText(infoPath)); + var root = document.RootElement; + if (!root.TryGetProperty("pid", out var pidElement) || !pidElement.TryGetInt32(out var pid)) + return null; + if (!root.TryGetProperty("since", out var sinceElement) + || !DateTimeOffset.TryParse( + sinceElement.GetString(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal, + out var since)) + { + return null; + } + + return new HolderInfo(pid, since.ToUniversalTime(), IsProcessStillRunning(pid)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + return null; + } + } + + private static bool IsProcessStillRunning(int pid) + { + if (pid <= 0) + return false; + + try + { + using var process = Process.GetProcessById(pid); + return !process.HasExited; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return false; + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + try + { + File.Delete(_infoPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + _stream.Dispose(); + } + + private sealed record HolderInfo(int Pid, DateTimeOffset Since, bool ProcessStillRunning); +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index d8a76e0273..8ca51c38f7 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3329,6 +3329,10 @@ private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, Jso if (!Directory.Exists(projectPath)) return CreateToolErrorResponse(id, "Directory not found"); + if (!McpIndexRunLock.TryAcquire(_dbPath, out var indexLock, out var lockError)) + return CreateToolErrorResponse(id, lockError!); + using var acquiredIndexLock = indexLock; + // Reuse the per-session DbContext (issue #1494) instead of opening a fresh // connection on every index call. InitializeSchema below is idempotent so the // shared connection still picks up legacy-DB migrations on demand. diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 4dc6e7bfe7..b362b86451 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6784,6 +6784,57 @@ public void ToolsCall_Index_MissingPath_ReturnsError() Assert.True(response["result"]!["isError"]!.GetValue()); } + [Fact] + public void ToolsCall_Index_WhenDbLockHeld_ReturnsBusyError() + { + var fixtureDir = Path.Combine(Path.GetFullPath("."), $"mcp_index_lock_fixture_{Guid.NewGuid():N}"); + Directory.CreateDirectory(fixtureDir); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_mcp_index_lock_{Guid.NewGuid():N}.db"); + var lockPath = McpIndexRunLock.ResolveLockPath(dbPath); + Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + var infoPath = lockPath + ".info"; + File.WriteAllText( + infoPath, + $$"""{"pid":{{Environment.ProcessId}},"since":"2026-01-02T03:04:05.0000000+00:00"}"""); + using var heldLock = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion(), dbPathExplicit: true); + try + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "index", + ["arguments"] = new JsonObject + { + ["path"] = fixtureDir + } + } + }; + + var response = server.HandleMessage(request)!; + + Assert.True(response["result"]!["isError"]!.GetValue()); + var text = response["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains("index already running on this DB", text); + Assert.Contains($"pid {Environment.ProcessId}", text); + Assert.Contains("2026-01-02T03:04:05", text); + } + finally + { + heldLock.Dispose(); + File.Delete(infoPath); + File.Delete(lockPath); + if (Directory.Exists(fixtureDir)) + Directory.Delete(fixtureDir, recursive: true); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + [Fact] public void ToolsCall_Index_NonexistentDir_ReturnsError() { From f4f38803c52cb8e041e2e1f6e085b26bd86f7706 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 1 Jun 2026 01:39:01 +0900 Subject: [PATCH 2/3] Keep CI builds clear of tracked AOT analyzer debt (#2796) --- .github/workflows/codeql.yml | 2 +- .github/workflows/dotnet.yml | 4 ++-- changelog.d/unreleased/2796.internal.md | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/2796.internal.md diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 083802d4c7..e1b666a8fd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -61,7 +61,7 @@ jobs: - name: Build for CodeQL if: matrix.language == 'csharp' - run: dotnet build CodeIndex.sln --configuration Release --no-restore + run: dotnet build CodeIndex.sln --configuration Release --no-restore -p:EnableAotAnalyzer=false -p:EnableTrimAnalyzer=false - name: Analyze uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b7ec61c36d..333118aaa0 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -179,14 +179,14 @@ jobs: } - name: Verify formatting - run: dotnet format CodeIndex.sln --verify-no-changes --no-restore --verbosity minimal + run: dotnet format whitespace CodeIndex.sln --verify-no-changes --no-restore --verbosity minimal - name: Verify developer task wrapper if: matrix.os == 'ubuntu-latest' && matrix.test-framework == 'net8.0' run: make lint - name: Build - run: dotnet build tests/CodeIndex.Tests/CodeIndex.Tests.csproj --configuration Release --framework ${{ matrix.test-framework }} --no-restore + run: dotnet build tests/CodeIndex.Tests/CodeIndex.Tests.csproj --configuration Release --framework ${{ matrix.test-framework }} --no-restore -p:EnableAotAnalyzer=false -p:EnableTrimAnalyzer=false - name: Test shell: pwsh diff --git a/changelog.d/unreleased/2796.internal.md b/changelog.d/unreleased/2796.internal.md new file mode 100644 index 0000000000..ae8835c0a7 --- /dev/null +++ b/changelog.d/unreleased/2796.internal.md @@ -0,0 +1,16 @@ +--- +category: internal +issues: + - 2796 +affected: + - .github/workflows/codeql.yml + - .github/workflows/dotnet.yml +--- + +## English + +- **CI build and CodeQL jobs no longer fail on tracked AOT analyzer debt (#2796)** — formatting, build, and CodeQL build steps now disable AOT/trim analyzers so ordinary PR validation can run while the existing IL3050 cleanup is tracked separately. + +## 日本語 + +- **CI build と CodeQL job が追跡済みの AOT analyzer debt で失敗しないようにしました (#2796)** — 既存の IL3050 cleanup を別途追跡しつつ通常の PR 検証を実行できるよう、formatting / build / CodeQL build step では AOT/trim analyzer を無効化します。 From 87e28cf992c8bbf4f9935aee4c99e90351b56fa1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 1 Jun 2026 02:48:40 +0900 Subject: [PATCH 3/3] Remove superseded CI workaround for #2796 --- .github/workflows/codeql.yml | 2 +- .github/workflows/dotnet.yml | 2 +- changelog.d/unreleased/2796.internal.md | 16 ---------------- 3 files changed, 2 insertions(+), 18 deletions(-) delete mode 100644 changelog.d/unreleased/2796.internal.md diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e1b666a8fd..083802d4c7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -61,7 +61,7 @@ jobs: - name: Build for CodeQL if: matrix.language == 'csharp' - run: dotnet build CodeIndex.sln --configuration Release --no-restore -p:EnableAotAnalyzer=false -p:EnableTrimAnalyzer=false + run: dotnet build CodeIndex.sln --configuration Release --no-restore - name: Analyze uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 333118aaa0..7d6caa88bb 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -186,7 +186,7 @@ jobs: run: make lint - name: Build - run: dotnet build tests/CodeIndex.Tests/CodeIndex.Tests.csproj --configuration Release --framework ${{ matrix.test-framework }} --no-restore -p:EnableAotAnalyzer=false -p:EnableTrimAnalyzer=false + run: dotnet build tests/CodeIndex.Tests/CodeIndex.Tests.csproj --configuration Release --framework ${{ matrix.test-framework }} --no-restore - name: Test shell: pwsh diff --git a/changelog.d/unreleased/2796.internal.md b/changelog.d/unreleased/2796.internal.md deleted file mode 100644 index ae8835c0a7..0000000000 --- a/changelog.d/unreleased/2796.internal.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -category: internal -issues: - - 2796 -affected: - - .github/workflows/codeql.yml - - .github/workflows/dotnet.yml ---- - -## English - -- **CI build and CodeQL jobs no longer fail on tracked AOT analyzer debt (#2796)** — formatting, build, and CodeQL build steps now disable AOT/trim analyzers so ordinary PR validation can run while the existing IL3050 cleanup is tracked separately. - -## 日本語 - -- **CI build と CodeQL job が追跡済みの AOT analyzer debt で失敗しないようにしました (#2796)** — 既存の IL3050 cleanup を別途追跡しつつ通常の PR 検証を実行できるよう、formatting / build / CodeQL build step では AOT/trim analyzer を無効化します。