From a61276f39e9cc8cfcb22510dbf9b28915abb8262 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 09:54:54 +0900 Subject: [PATCH 01/12] Fix MCP running flag visibility (#1736) --- src/CodeIndex/Mcp/McpServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 9960c44045..4a8d1787ad 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -61,7 +61,7 @@ public partial class McpServer : IDisposable private readonly AsyncLocal _currentRequestToken = new(); private readonly AsyncLocal?> _currentOutOfBandFrameWriter = new(); private readonly AsyncLocal?> _deferredFrameLogs = new(); - private bool _running = true; + private volatile bool _running = true; // Per-session DbContext reused across MCP tool calls. Holding the connection open // avoids reopening SQLite, reapplying pragmas, and re-registering every SQL function // on each invocation (issue #1494). From e29d154dd526cbdfa69901da6b0a143f07c51695 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 09:56:05 +0900 Subject: [PATCH 02/12] Add MCP request timeout handling (#1737) --- src/CodeIndex/Mcp/McpServer.cs | 32 +++++++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 64 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 4a8d1787ad..a14eedeaac 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; @@ -83,6 +84,8 @@ public partial class McpServer : IDisposable // ツール呼び出し監査ログ (#1562)。`--audit-log` 未指定時は null。AuditLogSink のライフサイクル // (ファイルハンドル / rotation) は ProgramRunner 側で所有する。 private readonly AuditLogSink? _auditLog; + private readonly TimeSpan _requestTimeout; + private readonly SemaphoreSlim _textWriterGate = new(1, 1); // `initialize.clientInfo` echoed into every audit record so the trail can answer // "which client issued this call?" without a second log source. Updated on every // `initialize` so a single-session reconnection picks up the new caller identity. @@ -149,6 +152,8 @@ public partial class McpServer : IDisposable // tool calls wedge the SQLite reader lock or balloon memory (#1567). // 同時 in-flight ツール呼び出し数の既定上限 (#1567)。 internal const int DefaultMaxConcurrency = 8; + internal static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(60); + internal static readonly TimeSpan DefaultEofDrainTimeout = TimeSpan.FromSeconds(5); public McpServer(string dbPath, string version, bool dbPathExplicit = false) : this(dbPath, version, dbPathExplicit, null, null, null, null, DefaultMaxConcurrency) @@ -231,6 +236,7 @@ internal McpServer(string dbPath, string version, bool dbPathExplicit, Func @@ -271,6 +277,7 @@ internal void OverrideRateLimiterForTests(RateLimiter limiter) internal string CurrentSessionId => _sessionId; internal Action? RequestRegisteredForTests { get; set; } + internal Func? RequestDelayForTests { get; set; } /// /// Cap configured for concurrent in-flight tool calls (#1567). Surfaced for tests so @@ -279,6 +286,14 @@ internal void OverrideRateLimiterForTests(RateLimiter limiter) /// internal int MaxConcurrency { get; } + internal TimeSpan RequestTimeout + { + get => _requestTimeout; + init => _requestTimeout = value <= TimeSpan.Zero + ? throw new ArgumentOutOfRangeException(nameof(value), value, "MCP request timeout must be greater than zero.") + : value; + } + /// /// Run the MCP server loop on the default stdio transport. Kept as a thin wrapper around /// so existing callers stay @@ -992,14 +1007,20 @@ private async Task DispatchWithRequestCancellationAsync(JsonNode? id, RequestRegisteredForTests?.Invoke(id); var previousToken = _currentRequestToken.Value; + var stopwatch = Stopwatch.StartNew(); try { _currentRequestToken.Value = requestCts.Token; + requestCts.CancelAfter(_requestTimeout); requestCts.Token.ThrowIfCancellationRequested(); + if (RequestDelayForTests is { } delay) + await delay(requestCts.Token).ConfigureAwait(false); return await action().ConfigureAwait(false); } catch (OperationCanceledException) when (requestCts.IsCancellationRequested) { + if (!previousToken.IsCancellationRequested && !_shutdownCts.IsCancellationRequested && stopwatch.Elapsed >= _requestTimeout) + return CreateRequestTimeoutResponse(id, stopwatch.Elapsed); return CreateCancelledResponse(id); } finally @@ -1009,6 +1030,17 @@ private async Task DispatchWithRequestCancellationAsync(JsonNode? id, } } + private static JsonObject CreateRequestTimeoutResponse(JsonNode? id, TimeSpan elapsed) + => CreateErrorResponse(hasId: true, id: id, code: -32603, message: "Request timed out", + category: McpErrorEnvelope.CategoryInternalError, + suggestion: "Retry with a narrower query, refresh the index if it is degraded, or increase the MCP request timeout before retrying.", + retrySafe: true, + extraData: new JsonObject + { + ["reason"] = "timeout", + ["elapsed_ms"] = (long)Math.Ceiling(elapsed.TotalMilliseconds), + }); + private void TryCancelRequest(JsonNode? cancelParams) { var requestId = cancelParams?["id"] ?? cancelParams?["requestId"]; diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index b908a8c253..a56ff03ee4 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -8439,6 +8439,45 @@ public void ToolsCall_Definition_AcceptsKindClassCaseInsensitively_Issue199() Assert.Equal(0, structuredWrongKind["count"]!.GetValue()); } + [Fact] + public async Task ProcessFrameAsync_RequestTimeout_ReturnsStructuredTimeoutError() + { + using var server = new McpServer(_dbPath, "1.0", dbPathExplicit: false) + { + RequestTimeout = TimeSpan.FromMilliseconds(20), + }; + server.RequestDelayForTests = token => Task.Delay(TimeSpan.FromSeconds(5), token); + + var responseText = await server.ProcessFrameAsync( + """{"jsonrpc":"2.0","id":123,"method":"tools/call","params":{"name":"status"}}"""); + + var response = JsonNode.Parse(responseText!)!; + var error = response["error"]!; + Assert.Equal(-32603, error["code"]!.GetValue()); + Assert.Equal("Request timed out", error["message"]!.GetValue()); + Assert.Equal("timeout", error["data"]!["reason"]!.GetValue()); + Assert.True(error["data"]!["elapsed_ms"]!.GetValue() >= 1); + Assert.Equal("internal_error", error["data"]!["category"]!.GetValue()); + Assert.True(error["data"]!["retry_safe"]!.GetValue()); + Assert.Equal(123, response["id"]!.GetValue()); + } + + [Fact] + public async Task RunAsync_StdioEofDrainsInFlightRequestBeforeReturning() + { + using var server = new McpServer(_dbPath, "1.0", dbPathExplicit: false); + server.RequestRegisteredForTests = _ => { }; + var transport = new QueuedFrameTransport( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status"}}"""); + + await server.RunAsync(transport, CancellationToken.None); + + Assert.Single(transport.WrittenFrames); + var response = JsonNode.Parse(transport.WrittenFrames[0]!)!; + Assert.Equal(1, response["id"]!.GetValue()); + Assert.Null(response["error"]); + } + private static string CreateLegacyDbWithoutIndexedAt() { var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_mcp_legacy_{Guid.NewGuid():N}.db"); @@ -9252,6 +9291,31 @@ public async Task RunAsync_CancelledBeforeAnyResponseDoesNotWriteFrame() Assert.Equal(0, transport.WriteCalls); } + private sealed class QueuedFrameTransport : IMcpTransport + { + private readonly Queue _frames; + + public QueuedFrameTransport(params string[] frames) + { + _frames = new Queue(frames.Cast().Append(null)); + } + + public string Name => "stdio"; + public string Endpoint => "memory://queued"; + public List WrittenFrames { get; } = []; + + public Task ReadFrameAsync(CancellationToken cancellationToken) + => Task.FromResult(_frames.Count == 0 ? null : _frames.Dequeue()); + + public Task WriteFrameAsync(string? frame, CancellationToken cancellationToken) + { + WrittenFrames.Add(frame); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + /// /// In-memory IMcpTransport whose ReadFrameAsync blocks until the supplied CancellationToken /// trips. Records read/write counts so tests can assert the loop actually entered the read From 8d75018636067d55ee210288aa54408c8c29f74b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 09:56:24 +0900 Subject: [PATCH 03/12] Serialize MCP text writer responses (#1738) --- src/CodeIndex/Mcp/McpServer.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index a14eedeaac..1ec0b81d80 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -621,8 +621,16 @@ internal async Task ProcessLineAsync(string line, TextWriter writer) { try { - await WriteJsonLineAsync(writer, response).ConfigureAwait(false); - FlushDeferredFrameLogs(); + await _textWriterGate.WaitAsync().ConfigureAwait(false); + try + { + await WriteJsonLineAsync(writer, response).ConfigureAwait(false); + FlushDeferredFrameLogs(); + } + finally + { + _textWriterGate.Release(); + } } catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) { From 66ff82ea17033cf626c8e7c0b3eb4e24d93fccdf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 09:56:32 +0900 Subject: [PATCH 04/12] Drain MCP stdio EOF in-flight work (#1742) --- .../unreleased/1736-1737-1738-1742.fixed.md | 19 +++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 28 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/1736-1737-1738-1742.fixed.md diff --git a/changelog.d/unreleased/1736-1737-1738-1742.fixed.md b/changelog.d/unreleased/1736-1737-1738-1742.fixed.md new file mode 100644 index 0000000000..7225183b05 --- /dev/null +++ b/changelog.d/unreleased/1736-1737-1738-1742.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 1736 + - 1737 + - 1738 + - 1742 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP server shutdown and request handling now have stronger concurrency guards (#1736, #1737, #1738, #1742)** — request processing now has a bounded timeout response, shared text-writer output is serialized, EOF drains in-flight stdio work with a grace period, and the run-loop flag uses volatile visibility. + +## 日本語 + +- **MCP server の shutdown と request handling の concurrency guard を強化しました (#1736, #1737, #1738, #1742)** — request 処理は bounded timeout response を返し、共有 TextWriter 出力を直列化し、EOF 時は stdio の in-flight 処理を猶予付きで drain し、run-loop flag は volatile visibility を使うようになりました。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 1ec0b81d80..56b4b0cc5e 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -602,10 +602,36 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella SpinWait.SpinUntil(() => !_running || _activeRequests.Count > 0, TimeSpan.FromMilliseconds(50)); } - await Task.WhenAll(tasks).ConfigureAwait(false); + await DrainInFlightTasksAsync(tasks, DefaultEofDrainTimeout).ConfigureAwait(false); Console.Error.WriteLine("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); } + private async Task DrainInFlightTasksAsync(List tasks, TimeSpan gracePeriod) + { + tasks.RemoveAll(task => task.IsCompleted); + if (tasks.Count == 0) + return; + + var allTasks = Task.WhenAll(tasks); + var completed = await Task.WhenAny(allTasks, Task.Delay(gracePeriod)).ConfigureAwait(false); + if (completed == allTasks) + { + await allTasks.ConfigureAwait(false); + return; + } + + Console.Error.WriteLine($"[cdidx-mcp] EOF reached with {tasks.Count} in-flight request(s); cancelling after {gracePeriod.TotalMilliseconds:0}ms grace period."); + try + { + if (!_shutdownCts.IsCancellationRequested) + _shutdownCts.Cancel(); + } + catch (ObjectDisposedException) + { + // Disposal raced EOF drain; no further action is possible. + } + } + /// /// Process one MCP JSON-RPC line and write any response to the provided writer. Kept as a /// thin wrapper around so existing tests that drive a From e985bbcf437c16f0839c9d1278a646e2ed081973 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 09:58:45 +0900 Subject: [PATCH 05/12] Fix changelog fragment name (#1736) --- .../unreleased/{1736-1737-1738-1742.fixed.md => 1736.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/unreleased/{1736-1737-1738-1742.fixed.md => 1736.fixed.md} (100%) diff --git a/changelog.d/unreleased/1736-1737-1738-1742.fixed.md b/changelog.d/unreleased/1736.fixed.md similarity index 100% rename from changelog.d/unreleased/1736-1737-1738-1742.fixed.md rename to changelog.d/unreleased/1736.fixed.md From 5ff4d11efb6e953065b6ca28caf6921646f874b9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:16:54 +0900 Subject: [PATCH 06/12] Enforce MCP timeout for non-cooperative handlers (#1737) --- src/CodeIndex/Mcp/McpServer.cs | 11 ++++++++++- tests/CodeIndex.Tests/McpServerTests.cs | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 56b4b0cc5e..aec63cadd2 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -1049,7 +1049,16 @@ private async Task DispatchWithRequestCancellationAsync(JsonNode? id, requestCts.Token.ThrowIfCancellationRequested(); if (RequestDelayForTests is { } delay) await delay(requestCts.Token).ConfigureAwait(false); - return await action().ConfigureAwait(false); + var actionTask = action(); + var completed = await Task.WhenAny(actionTask, Task.Delay(_requestTimeout)).ConfigureAwait(false); + if (completed != actionTask) + { + try { requestCts.Cancel(); } + catch (ObjectDisposedException) { /* completed while timeout cancellation was being delivered. */ } + return CreateRequestTimeoutResponse(id, stopwatch.Elapsed); + } + + return await actionTask.ConfigureAwait(false); } catch (OperationCanceledException) when (requestCts.IsCancellationRequested) { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index a56ff03ee4..bf0f035965 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -8446,7 +8446,7 @@ public async Task ProcessFrameAsync_RequestTimeout_ReturnsStructuredTimeoutError { RequestTimeout = TimeSpan.FromMilliseconds(20), }; - server.RequestDelayForTests = token => Task.Delay(TimeSpan.FromSeconds(5), token); + server.RequestDelayForTests = _ => Task.Delay(TimeSpan.FromSeconds(5)); var responseText = await server.ProcessFrameAsync( """{"jsonrpc":"2.0","id":123,"method":"tools/call","params":{"name":"status"}}"""); From 3aae065e257a1dc89f0ce8e87cabf8c5dfa9e09d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:21:39 +0900 Subject: [PATCH 07/12] Bound synchronous MCP dispatch timeout (#1737) --- src/CodeIndex/Mcp/McpServer.cs | 26 ++++++++++++++++++++----- tests/CodeIndex.Tests/McpServerTests.cs | 6 +++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index aec63cadd2..abce1b3f7c 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -1030,9 +1030,10 @@ private async Task DispatchWithRequestCancellationAsync(JsonNode? id, if (requestKey == null) return await action().ConfigureAwait(false); - using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(_currentRequestToken.Value, _shutdownCts.Token); + var requestCts = CancellationTokenSource.CreateLinkedTokenSource(_currentRequestToken.Value, _shutdownCts.Token); if (!_activeRequests.TryAdd(requestKey, requestCts)) { + requestCts.Dispose(); return CreateErrorResponse(hasId: true, id: id, code: -32600, message: "Duplicate in-flight request id", category: McpErrorEnvelope.CategoryInvalidRequest, suggestion: "JSON-RPC request ids must be unique while a previous request with the same id is still running.", @@ -1042,19 +1043,30 @@ private async Task DispatchWithRequestCancellationAsync(JsonNode? id, var previousToken = _currentRequestToken.Value; var stopwatch = Stopwatch.StartNew(); + var cleanupNow = true; try { _currentRequestToken.Value = requestCts.Token; requestCts.CancelAfter(_requestTimeout); requestCts.Token.ThrowIfCancellationRequested(); - if (RequestDelayForTests is { } delay) - await delay(requestCts.Token).ConfigureAwait(false); - var actionTask = action(); + var actionTask = Task.Run(async () => + { + if (RequestDelayForTests is { } delay) + await delay(requestCts.Token).ConfigureAwait(false); + return await action().ConfigureAwait(false); + }, requestCts.Token); var completed = await Task.WhenAny(actionTask, Task.Delay(_requestTimeout)).ConfigureAwait(false); if (completed != actionTask) { try { requestCts.Cancel(); } catch (ObjectDisposedException) { /* completed while timeout cancellation was being delivered. */ } + cleanupNow = false; + _ = actionTask.ContinueWith(task => + { + _ = task.Exception; + _activeRequests.TryRemove(requestKey, out _); + requestCts.Dispose(); + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); return CreateRequestTimeoutResponse(id, stopwatch.Elapsed); } @@ -1069,7 +1081,11 @@ private async Task DispatchWithRequestCancellationAsync(JsonNode? id, finally { _currentRequestToken.Value = previousToken; - _activeRequests.TryRemove(requestKey, out _); + if (cleanupNow) + { + _activeRequests.TryRemove(requestKey, out _); + requestCts.Dispose(); + } } } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index bf0f035965..855ba821ca 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -8446,7 +8446,11 @@ public async Task ProcessFrameAsync_RequestTimeout_ReturnsStructuredTimeoutError { RequestTimeout = TimeSpan.FromMilliseconds(20), }; - server.RequestDelayForTests = _ => Task.Delay(TimeSpan.FromSeconds(5)); + server.RequestDelayForTests = _ => + { + Thread.Sleep(TimeSpan.FromMilliseconds(200)); + return Task.CompletedTask; + }; var responseText = await server.ProcessFrameAsync( """{"jsonrpc":"2.0","id":123,"method":"tools/call","params":{"name":"status"}}"""); From 519eb9d5787b0b7f80acecd132bdf86d6de17603 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:26:17 +0900 Subject: [PATCH 08/12] Isolate timed MCP request DB access (#1737) --- src/CodeIndex/Mcp/McpServer.cs | 62 ++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index abce1b3f7c..95abe3ca02 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -60,6 +60,7 @@ public partial class McpServer : IDisposable // 直後にリセットする。`WithDbReader` が `DbReader` にライブな cancellation token // を渡せるようにするため (#1567)。 private readonly AsyncLocal _currentRequestToken = new(); + private readonly AsyncLocal _isolateDbForCurrentRequest = new(); private readonly AsyncLocal?> _currentOutOfBandFrameWriter = new(); private readonly AsyncLocal?> _deferredFrameLogs = new(); private volatile bool _running = true; @@ -740,7 +741,7 @@ internal static string BuildInvalidUtf8ErrorLog(string detail) return null; ExtractResponseId(request, out responseHasId, out responseId); - var response = await HandleMessageAsync(request).ConfigureAwait(false); + var response = await HandleMessageAsync(request, isolateRequestDb: true).ConfigureAwait(false); return response != null ? SerializeResponseOrFallback(response, responseHasId, responseId) : null; } catch (JsonException ex) @@ -852,9 +853,12 @@ private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id /// JSON-RPCメッセージを適切なハンドラにルーティング。 /// internal JsonNode? HandleMessage(JsonNode request) - => HandleMessageAsync(request).GetAwaiter().GetResult(); + => HandleMessageAsync(request, isolateRequestDb: false).GetAwaiter().GetResult(); - internal async Task HandleMessageAsync(JsonNode request) + internal Task HandleMessageAsync(JsonNode request) + => HandleMessageAsync(request, isolateRequestDb: false); + + private async Task HandleMessageAsync(JsonNode request, bool isolateRequestDb) { if (request is JsonArray batch) return HandleBatchMessage(batch); @@ -953,7 +957,7 @@ private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id retrySafe: false); } - return await DispatchWithRequestCancellationAsync(id, () => method switch + return await DispatchWithRequestCancellationAsync(id, isolateRequestDb, () => method switch { "initialize" => Task.FromResult(HandleInitialize(id, request["params"])), "tools/list" => Task.FromResult(HandleToolsList(id)), @@ -1022,9 +1026,9 @@ private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id } private JsonNode DispatchWithRequestCancellation(JsonNode? id, Func action) - => DispatchWithRequestCancellationAsync(id, () => Task.FromResult(action())).GetAwaiter().GetResult(); + => DispatchWithRequestCancellationAsync(id, isolateRequestDb: false, () => Task.FromResult(action())).GetAwaiter().GetResult(); - private async Task DispatchWithRequestCancellationAsync(JsonNode? id, Func> action) + private async Task DispatchWithRequestCancellationAsync(JsonNode? id, bool isolateRequestDb, Func> action) { var requestKey = SerializeRequestId(id); if (requestKey == null) @@ -1049,11 +1053,27 @@ private async Task DispatchWithRequestCancellationAsync(JsonNode? id, _currentRequestToken.Value = requestCts.Token; requestCts.CancelAfter(_requestTimeout); requestCts.Token.ThrowIfCancellationRequested(); - var actionTask = Task.Run(async () => + if (!isolateRequestDb) { if (RequestDelayForTests is { } delay) await delay(requestCts.Token).ConfigureAwait(false); return await action().ConfigureAwait(false); + } + + var actionTask = Task.Run(async () => + { + var previousIsolation = _isolateDbForCurrentRequest.Value; + _isolateDbForCurrentRequest.Value = isolateRequestDb; + try + { + if (RequestDelayForTests is { } delay) + await delay(requestCts.Token).ConfigureAwait(false); + return await action().ConfigureAwait(false); + } + finally + { + _isolateDbForCurrentRequest.Value = previousIsolation; + } }, requestCts.Token); var completed = await Task.WhenAny(actionTask, Task.Delay(_requestTimeout)).ConfigureAwait(false); if (completed != actionTask) @@ -2151,14 +2171,25 @@ private JsonNode WithDbReader(JsonNode? id, JsonNode? args, Func() ?? false; + return isolatedReader.RunWithGeneratedScope(() => action(isolatedReader)); + } + var db = GetOrOpenSharedDb(); if (!_sharedDbReadMigrated) { db.TryMigrateForRead(); _sharedDbReadMigrated = true; } - // Reuse the connection-scoped schema cache so each MCP tool call no longer - // re-runs PRAGMA table_info / PRAGMA index_list per DbReader (issue #1565), + // Reuse the connection-scoped schema cache for single-threaded direct callers so each + // call no longer re-runs PRAGMA table_info / PRAGMA index_list per DbReader (issue #1565), // and hand the per-request cancellation token to the reader so SQLite work // the tool kicks off can observe shutdown / client-disconnect cancellation // (#1567). The token is `CancellationToken.None` outside an in-flight request, @@ -2167,21 +2198,18 @@ private JsonNode WithDbReader(JsonNode? id, JsonNode? args, Func() ?? false; return reader.RunWithGeneratedScope(() => action(reader)); } /// - /// Open the per-session DbContext on first use and reuse it on every subsequent call. + /// Open the per-session DbContext on first use and reuse it on every subsequent direct call. /// Centralising the open lets us pay the connection setup, pragma application, and SQL - /// function registration once per MCP session instead of once per tool invocation - /// (#1494). The MCP loop is single-threaded, so no locking is required. - /// MCP セッション初回呼び出し時に DbContext を開き、以後は再利用する。接続セットアップや - /// PRAGMA・SQL 関数登録のコストを毎ツール呼び出しごとに払わないようにする(#1494)。 - /// MCP ループは単一スレッドのためロック不要。 + /// function registration once per direct session instead of once per tool invocation + /// (#1494). Transport requests that may time out independently use isolated DB contexts. + /// 直接呼び出しセッション初回に DbContext を開き、以後は再利用する。timeout 後も独立して + /// 継続し得る transport リクエストは、共有接続を避けるためリクエスト単位の DB context を使う。 /// internal DbContext GetOrOpenSharedDb() { From 790ce878ef4faffd0fc54275040daa0fcd872cc2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:28:48 +0900 Subject: [PATCH 09/12] Settle MCP EOF drain tasks (#1742) --- src/CodeIndex/Mcp/McpServer.cs | 35 +++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 95abe3ca02..a7298bbebd 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -155,6 +155,7 @@ public partial class McpServer : IDisposable internal const int DefaultMaxConcurrency = 8; internal static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(60); internal static readonly TimeSpan DefaultEofDrainTimeout = TimeSpan.FromSeconds(5); + internal static readonly TimeSpan DefaultEofPostCancelDrainTimeout = TimeSpan.FromSeconds(5); public McpServer(string dbPath, string version, bool dbPathExplicit = false) : this(dbPath, version, dbPathExplicit, null, null, null, null, DefaultMaxConcurrency) @@ -501,8 +502,8 @@ internal async Task RunAsync(IMcpTransport transport, CancellationToken cancella private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, CancellationToken loopToken) { - using var writeGate = new SemaphoreSlim(1, 1); - using var normalFrameGate = new SemaphoreSlim(1, 1); + var writeGate = new SemaphoreSlim(1, 1); + var normalFrameGate = new SemaphoreSlim(1, 1); var tasks = new List(); while (_running) @@ -603,11 +604,11 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella SpinWait.SpinUntil(() => !_running || _activeRequests.Count > 0, TimeSpan.FromMilliseconds(50)); } - await DrainInFlightTasksAsync(tasks, DefaultEofDrainTimeout).ConfigureAwait(false); + await DrainInFlightTasksAsync(tasks, DefaultEofDrainTimeout, DefaultEofPostCancelDrainTimeout).ConfigureAwait(false); Console.Error.WriteLine("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); } - private async Task DrainInFlightTasksAsync(List tasks, TimeSpan gracePeriod) + private async Task DrainInFlightTasksAsync(List tasks, TimeSpan gracePeriod, TimeSpan postCancelGracePeriod) { tasks.RemoveAll(task => task.IsCompleted); if (tasks.Count == 0) @@ -617,7 +618,7 @@ private async Task DrainInFlightTasksAsync(List tasks, TimeSpan gracePerio var completed = await Task.WhenAny(allTasks, Task.Delay(gracePeriod)).ConfigureAwait(false); if (completed == allTasks) { - await allTasks.ConfigureAwait(false); + await ObserveInFlightTasksAsync(allTasks).ConfigureAwait(false); return; } @@ -631,6 +632,30 @@ private async Task DrainInFlightTasksAsync(List tasks, TimeSpan gracePerio { // Disposal raced EOF drain; no further action is possible. } + + completed = await Task.WhenAny(allTasks, Task.Delay(postCancelGracePeriod)).ConfigureAwait(false); + if (completed == allTasks) + { + await ObserveInFlightTasksAsync(allTasks).ConfigureAwait(false); + return; + } + + _ = allTasks.ContinueWith(task => + { + _ = task.Exception; + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + + private static async Task ObserveInFlightTasksAsync(Task tasks) + { + try + { + await tasks.ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[cdidx-mcp] In-flight request ended during EOF drain ({ex.GetType().Name})."); + } } /// From 8e8b6d01940aaf56496f50b70f0de74819fbfee4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:31:14 +0900 Subject: [PATCH 10/12] Apply MCP timeouts to batch items (#1737) --- src/CodeIndex/Mcp/McpServer.cs | 6 +++--- tests/CodeIndex.Tests/McpServerTests.cs | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index a7298bbebd..f1c80d064e 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -886,7 +886,7 @@ private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id private async Task HandleMessageAsync(JsonNode request, bool isolateRequestDb) { if (request is JsonArray batch) - return HandleBatchMessage(batch); + return await HandleBatchMessageAsync(batch, isolateRequestDb).ConfigureAwait(false); if (request is not JsonObject obj) return CreateErrorResponse(hasId: false, id: null, code: -32600, message: "Invalid request: expected JSON object", @@ -999,7 +999,7 @@ private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id }).ConfigureAwait(false); } - private JsonNode? HandleBatchMessage(JsonArray batch) + private async Task HandleBatchMessageAsync(JsonArray batch, bool isolateRequestDb) { if (batch.Count == 0) return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: empty batch", @@ -1040,7 +1040,7 @@ private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id } else { - response = HandleMessage(item); + response = await HandleMessageAsync(item, isolateRequestDb).ConfigureAwait(false); } if (response != null) diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 855ba821ca..4654571113 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -8466,6 +8466,30 @@ public async Task ProcessFrameAsync_RequestTimeout_ReturnsStructuredTimeoutError Assert.Equal(123, response["id"]!.GetValue()); } + [Fact] + public async Task ProcessFrameAsync_BatchRequestTimeout_ReturnsStructuredTimeoutError() + { + using var server = new McpServer(_dbPath, "1.0", dbPathExplicit: false) + { + RequestTimeout = TimeSpan.FromMilliseconds(20), + }; + server.RequestDelayForTests = _ => + { + Thread.Sleep(TimeSpan.FromMilliseconds(200)); + return Task.CompletedTask; + }; + + var responseText = await server.ProcessFrameAsync( + """[{"jsonrpc":"2.0","id":123,"method":"tools/call","params":{"name":"status"}}]"""); + + var response = JsonNode.Parse(responseText!)!.AsArray().Single()!; + var error = response["error"]!; + Assert.Equal(-32603, error["code"]!.GetValue()); + Assert.Equal("Request timed out", error["message"]!.GetValue()); + Assert.Equal("timeout", error["data"]!["reason"]!.GetValue()); + Assert.Equal(123, response["id"]!.GetValue()); + } + [Fact] public async Task RunAsync_StdioEofDrainsInFlightRequestBeforeReturning() { From 2f14b36312d4bd692ffdba270634ab47a47b554d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 12:36:53 +0900 Subject: [PATCH 11/12] Dispose file upsert returning reader deterministically --- src/CodeIndex/Database/DbWriter.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Database/DbWriter.cs b/src/CodeIndex/Database/DbWriter.cs index 36483f343b..085f3b5b74 100644 --- a/src/CodeIndex/Database/DbWriter.cs +++ b/src/CodeIndex/Database/DbWriter.cs @@ -659,7 +659,10 @@ ON CONFLICT(path) DO UPDATE SET cmd.Parameters["@checksum"].Value = (object?)file.Checksum ?? DBNull.Value; cmd.Parameters["@modified"].Value = file.Modified; cmd.Parameters["@generated"].Value = file.Generated ? 1 : 0; - return (long)cmd.ExecuteScalar()!; + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + throw new InvalidOperationException("SQLite RETURNING id produced no row for file upsert."); + return reader.GetInt64(0); } finally { From 6bf7a3c88de0e670d1b51ca1ed0efb8bab4951b2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 18:50:09 +0900 Subject: [PATCH 12/12] Stabilize MCP console telemetry tests on Windows --- tests/CodeIndex.Tests/McpServerTests.cs | 60 ++++++++++++------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 04ae6e7bff..37598c09da 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -151,24 +151,24 @@ public async Task ProcessLineAsync_ToolCallEmitsInvocationTelemetry() using var writer = new StringWriter(); using var error = new StringWriter(); - Monitor.Enter(TestConsoleLock.Gate); - try + await Task.Run(() => { - var previousError = Console.Error; - try + lock (TestConsoleLock.Gate) { - Console.SetError(error); - await _server.ProcessLineAsync("""{"jsonrpc":"2.0","id":123,"method":"tools/call","params":{"name":"ping","arguments":{}}}""", writer); - } - finally - { - Console.SetError(previousError); + var previousError = Console.Error; + try + { + Console.SetError(error); +#pragma warning disable xUnit1031 + _server.ProcessLineAsync("""{"jsonrpc":"2.0","id":123,"method":"tools/call","params":{"name":"ping","arguments":{}}}""", writer).GetAwaiter().GetResult(); +#pragma warning restore xUnit1031 + } + finally + { + Console.SetError(previousError); + } } - } - finally - { - Monitor.Exit(TestConsoleLock.Gate); - } + }); var line = error.ToString() .Split('\n', StringSplitOptions.RemoveEmptyEntries) @@ -191,24 +191,24 @@ public async Task ProcessLineAsync_FallbackErrorIncludesCorrelationData() using var writer = new StringWriter(); using var error = new StringWriter(); - Monitor.Enter(TestConsoleLock.Gate); - try + await Task.Run(() => { - var previousError = Console.Error; - try + lock (TestConsoleLock.Gate) { - Console.SetError(error); - await _server.ProcessLineAsync("""{"jsonrpc":"2.0","id":321,"method":"tools/call","params":{"name":42,"arguments":{}}}""", writer); - } - finally - { - Console.SetError(previousError); + var previousError = Console.Error; + try + { + Console.SetError(error); +#pragma warning disable xUnit1031 + _server.ProcessLineAsync("""{"jsonrpc":"2.0","id":321,"method":"tools/call","params":{"name":42,"arguments":{}}}""", writer).GetAwaiter().GetResult(); +#pragma warning restore xUnit1031 + } + finally + { + Console.SetError(previousError); + } } - } - finally - { - Monitor.Exit(TestConsoleLock.Gate); - } + }); var response = JsonNode.Parse(writer.ToString())!; var data = response["error"]!["data"]!;