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 f144040475..514a49540a 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3381,6 +3381,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 b84292aeb3..2582fff480 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6855,6 +6855,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() {