diff --git a/changelog.d/unreleased/1714.fixed.md b/changelog.d/unreleased/1714.fixed.md new file mode 100644 index 0000000000..2b8b88e625 --- /dev/null +++ b/changelog.d/unreleased/1714.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1714 +affected: + - src/CodeIndex/Database/DbReader.cs + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs +--- + +## English + +- **MCP DbReader lifetimes are now disposed deterministically (#1714)** - per-request readers and the index-project signal reader release their per-reader caches when the call finishes. + +## 日本語 + +- **MCP の DbReader lifetime を決定的に破棄するようにしました (#1714)** - リクエスト単位の reader と index-project の signal reader が、呼び出し終了時に reader 内キャッシュを解放します。 diff --git a/changelog.d/unreleased/1716.fixed.md b/changelog.d/unreleased/1716.fixed.md new file mode 100644 index 0000000000..dde828f0ed --- /dev/null +++ b/changelog.d/unreleased/1716.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 1716 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs +--- + +## English + +- **MCP server startup now handles loop failures before process exit (#1716)** - stdio and HTTP MCP entry points log unexpected failures and flush stdout/stderr before returning an error exit code. + +## 日本語 + +- **MCP server 起動経路がループ失敗をプロセス終了前に処理するようにしました (#1716)** - stdio / HTTP MCP の entry point は予期しない失敗をログに残し、stdout/stderr を flush してからエラー終了します。 diff --git a/changelog.d/unreleased/1720.fixed.md b/changelog.d/unreleased/1720.fixed.md new file mode 100644 index 0000000000..46c205b30e --- /dev/null +++ b/changelog.d/unreleased/1720.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 1720 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs +--- + +## English + +- **MCP index now reports per-file failures instead of silently swallowing them (#1720)** - index responses include `failed_count` and a capped failure list when individual files fail. + +## 日本語 + +- **MCP index がファイル単位の失敗を黙殺せず報告するようにしました (#1720)** - 個別ファイルの処理に失敗した場合、応答に `failed_count` と上限付きの失敗一覧を含めます。 diff --git a/changelog.d/unreleased/2010.fixed.md b/changelog.d/unreleased/2010.fixed.md new file mode 100644 index 0000000000..ca4dc7cf09 --- /dev/null +++ b/changelog.d/unreleased/2010.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 2010 +affected: + - src/CodeIndex/Database/DbDebug.cs +--- + +## English + +- **DbDebug now preserves row-read exception chains (#2010)** - debug dumps include exception chains captured while snapshotting row fields and a root-cause line for faster diagnosis. + +## 日本語 + +- **DbDebug が row read 例外チェーンを保持するようにしました (#2010)** - 行フィールドの snapshot 中に捕捉した例外チェーンと root-cause 行を debug dump に含め、原因特定を容易にします。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 85d6b7509e..a7021b5e65 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -1451,8 +1451,25 @@ private static int RunMcp(string[] cmdArgs, string appVersion) if (string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) return RunMcpHttp(server, listenSpec ?? DefaultMcpHttpListen); - server.RunAsync().GetAwaiter().GetResult(); - return CommandExitCodes.Success; + try + { + server.RunAsync().GetAwaiter().GetResult(); + return CommandExitCodes.Success; + } + catch (OperationCanceledException) + { + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.CancelledBySignal; + } + catch (Exception ex) + { + GlobalToolLog.Error("mcp_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); + Console.Error.WriteLine($"Error: MCP server failed ({ex.GetType().Name}: {ex.Message})."); + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.DatabaseError; + } } finally { @@ -1516,7 +1533,24 @@ private static int RunMcpHttp(McpServer server, string listenSpec) else Console.Error.WriteLine($"[cdidx-mcp] HTTP transport listening on {resolved.Prefix} (bearer auth required)."); - server.RunAsync(transport, cts.Token).GetAwaiter().GetResult(); + try + { + server.RunAsync(transport, cts.Token).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.CancelledBySignal; + } + catch (Exception ex) + { + GlobalToolLog.Error("mcp_http_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); + Console.Error.WriteLine($"Error: MCP HTTP server failed ({ex.GetType().Name}: {ex.Message})."); + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.DatabaseError; + } } } finally diff --git a/src/CodeIndex/Database/DbDebug.cs b/src/CodeIndex/Database/DbDebug.cs index 562982272a..64ef32dc8b 100644 --- a/src/CodeIndex/Database/DbDebug.cs +++ b/src/CodeIndex/Database/DbDebug.cs @@ -40,6 +40,10 @@ public static class DbDebug [ThreadStatic] private static List<(string Name, string Value)>? _lastRow; [ThreadStatic] + private static List? _lastRowReadExceptionChains; + [ThreadStatic] + private static List? _lastRowReadExceptions; + [ThreadStatic] private static bool _hasContext; [ThreadStatic] private static List? _profileEntries; @@ -169,6 +173,8 @@ public static void ResetContext() _lastSql = null; _lastParams = null; _lastRow = null; + _lastRowReadExceptionChains = null; + _lastRowReadExceptions = null; _hasContext = false; } @@ -314,6 +320,9 @@ internal static void SnapshotRow(SqliteDataReader reader) catch (Exception ex) { row.Add((name, $"")); + (_lastRowReadExceptions ??= new List()).Add(ex); + (_lastRowReadExceptionChains ??= new List()) + .Add($"[{name}]\n{GlobalToolLog.FormatExceptionChain(ex, includeStacks: mode == DebugMode.Unsafe)}"); } } _lastRow = row; @@ -356,6 +365,15 @@ public static void DumpToStderr(Exception ex) foreach (var (name, value) in _lastRow) sb.AppendLine($" [{name}] = {value}"); } + if (_lastRowReadExceptionChains is { Count: > 0 }) + { + sb.AppendLine("Row read exception chains:"); + foreach (var chain in _lastRowReadExceptionChains) + sb.AppendLine(chain); + } + var rootCause = GetDeepestExceptionIncludingRowReads(ex); + if (_lastRowReadExceptionChains is { Count: > 0 }) + sb.AppendLine($"Root cause: {rootCause.GetType().Name}: {rootCause.Message}"); if (mode != DebugMode.Unsafe && ex.StackTrace != null) { sb.AppendLine("Stack:"); @@ -365,6 +383,25 @@ public static void DumpToStderr(Exception ex) Console.Error.Write(sb.ToString()); } + private static Exception GetDeepestException(Exception ex) + { + var current = ex; + while (current.InnerException != null) + current = current.InnerException; + return current; + } + + private static Exception GetDeepestExceptionIncludingRowReads(Exception ex) + { + var deepest = GetDeepestException(ex); + if (_lastRowReadExceptions is not { Count: > 0 }) + return deepest; + + foreach (var rowException in _lastRowReadExceptions) + deepest = GetDeepestException(rowException); + return deepest; + } + private static string FormatValue(object? value, DebugMode mode, string? valueName = null) { if (value is null || value is DBNull) diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index 6710d334a9..16c965eef4 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -28,7 +28,7 @@ public readonly record struct SqlGraphContractSignal( /// Handles read/query operations against the database for search, symbols, and files. /// 検索・シンボル・ファイル一覧などのDB読み取り操作を担当する。 /// -public partial class DbReader +public partial class DbReader : IDisposable { public const string VerifyFoldReadyRowsEnvironmentVariable = "CDIDX_VERIFY_FOLD_READY_ROWS"; internal const int MaxReferenceKindAggregateCharacters = 16 * 1024; @@ -65,6 +65,7 @@ public partial class DbReader private readonly Dictionary> _csharpTypeContainingTypesByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _csharpInheritedContainingTypesByQualifiedName = new(StringComparer.Ordinal); private readonly Dictionary _csharpContainingTypeScopeByQualifiedName = new(StringComparer.Ordinal); + private bool _disposed; private HashSet? _csharpGlobalUsingStaticTargets; private HashSet? _csharpGlobalUsingNamespaces; private Dictionary? _csharpGlobalUsingAliasesByName; @@ -563,6 +564,30 @@ private DbReader( (_indexNewerThanReader, _indexNewerThanReaderReason) = DetectNewerThanReaderContracts(_conn, userVersion); } + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _csharpUsingStaticScopesByPath.Clear(); + _csharpNamespaceScopesByPath.Clear(); + _csharpContainingTypeScopesByPath.Clear(); + _csharpUsingNamespaceScopesByPath.Clear(); + _csharpUsingAliasScopesByPath.Clear(); + _activeCSharpTypeNamespacesByPathLine.Clear(); + _activeCSharpContainingTypeScopesByPathLine.Clear(); + _activeCSharpUsingStaticTargetsByPathLine.Clear(); + _csharpConstantPatternContainersByMemberName.Clear(); + _csharpTypeNamespacesByName.Clear(); + _csharpTypeContainingTypesByName.Clear(); + _csharpInheritedContainingTypesByQualifiedName.Clear(); + _csharpContainingTypeScopeByQualifiedName.Clear(); + _csharpGlobalUsingStaticTargets = null; + _csharpGlobalUsingNamespaces = null; + _csharpGlobalUsingAliasesByName = null; + } + /// /// Per-request CancellationToken plumbed in by the caller (e.g. the MCP server). Methods /// that loop over SQLite rows can check this between batches to bail out promptly on diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index c705c32fc7..8f0f97d0bd 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -2518,7 +2518,7 @@ private JsonNode WithDbReader(JsonNode? id, JsonNode? args, Func() ?? false; return isolatedReader.RunWithGeneratedScope(() => action(isolatedReader)); } @@ -2539,7 +2539,7 @@ private JsonNode WithDbReader(JsonNode? id, JsonNode? args, Func() ?? false; return reader.RunWithGeneratedScope(() => action(reader)); } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index a7f42aa0c0..5a4afaa22e 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2899,6 +2899,7 @@ void WriteProjectRootOnce() if (purged > 0 && hadCSharpStaticInterfaceContractsBeforePurge) csharpWorkspace = csharpWorkspace with { HasStaticInterfaceContracts = true }; int processed = 0, skipped = 0, errors = 0; + var failures = new List(); var reusedHotspotFamilyLanguages = new HashSet(StringComparer.Ordinal); foreach (var filePath in files) @@ -2970,9 +2971,10 @@ void WriteProjectRootOnce() txn.Commit(); } } - catch + catch (Exception ex) { errors++; + failures.Add(BuildIndexFileFailure(projectPath, filePath, ex, "delete_skipped_binary")); } } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) @@ -2991,9 +2993,10 @@ void WriteProjectRootOnce() txn.Commit(); } } - catch + catch (Exception cleanupEx) { errors++; + failures.Add(BuildIndexFileFailure(projectPath, filePath, cleanupEx, "delete_missing_file")); } } catch (OperationCanceledException) when (requestToken.IsCancellationRequested) @@ -3002,11 +3005,12 @@ void WriteProjectRootOnce() writer.ClearBatchInProgress(); throw; } - catch + catch (Exception ex) { if (fileBatchMarked) writer.ClearBatchInProgress(); errors++; + failures.Add(BuildIndexFileFailure(projectPath, filePath, ex, "index_file")); } processed++; EmitProgressNotification(progressToken, processed, files.Count); @@ -3160,7 +3164,8 @@ void WriteProjectRootOnce() ["skipped"] = skipped, ["purged"] = purged, ["unknown_extension_file_count"] = scanResult.UnknownExtensionFiles.Count, - ["errors"] = errors + ["errors"] = errors, + ["failed_count"] = failures.Count }, ["sql_graph_contract_ready"] = sqlGraphContractReadyAfter, ["csharp_symbol_name_ready"] = csharpSymbolNameReadyAfter, @@ -3170,9 +3175,28 @@ void WriteProjectRootOnce() ["fold_ready"] = foldReadyAfter, ["fold_ready_reason"] = foldReadyReason }; + if (failures.Count > 0) + { + var failureArray = new JsonArray(); + foreach (var failure in failures.Take(50)) + { + failureArray.Add(new JsonObject + { + ["path"] = failure.Path, + ["stage"] = failure.Stage, + ["exception_type"] = failure.ExceptionType, + ["message"] = failure.Message, + }); + } + structured["failed_count"] = failures.Count; + structured["failures"] = failureArray; + if (failures.Count > 50) + structured["failures_truncated"] = failures.Count - 50; + GlobalToolLog.Error($"mcp_index_file_failures count={failures.Count} first_path='{failures[0].Path}' first_error='{failures[0].ExceptionType}: {failures[0].Message}'"); + } if (!sqlGraphContractReadyAfter) { - var signalReader = new DbReader(writer.Connection); + using var signalReader = new DbReader(writer.Connection); AddSqlGraphContractSignal(structured, signalReader.GetSqlGraphContractSignal()); } return CreateToolResult(id, @@ -3188,6 +3212,14 @@ void WriteProjectRootOnce() structured); } + private static IndexFileFailure BuildIndexFileFailure(string projectPath, string filePath, Exception ex, string stage) + { + var relativePath = FileIndexer.NormalizePathSeparators(Path.GetRelativePath(projectPath, filePath)); + return new IndexFileFailure(relativePath, stage, ex.GetType().Name, ex.Message); + } + + private sealed record IndexFileFailure(string Path, string Stage, string ExceptionType, string Message); + private JsonNode ExecuteBackfillFold(JsonNode? id, JsonNode? progressToken = null) { if (!DbContext.TryValidateExistingCodeIndexDb(_dbPath, out var validationMessage, out var isNotFound))