Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1459.fixed.md
Original file line number Diff line number Diff line change
@@ -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 を返します。
149 changes: 149 additions & 0 deletions src/CodeIndex/Mcp/McpIndexRunLock.cs
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 4 additions & 0 deletions src/CodeIndex/Mcp/McpToolHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3381,6 +3381,10 @@ private async Task<JsonNode> 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.
Expand Down
51 changes: 51 additions & 0 deletions tests/CodeIndex.Tests/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6855,6 +6855,57 @@ public void ToolsCall_Index_MissingPath_ReturnsError()
Assert.True(response["result"]!["isError"]!.GetValue<bool>());
}

[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<bool>());
var text = response["result"]!["content"]![0]!["text"]!.GetValue<string>();
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()
{
Expand Down
Loading