From 3697fb8d9cd0adc8c9c7c5708b0656a2c48529de Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 04:59:03 +0900 Subject: [PATCH 1/3] Fix C va_arg typedef references (#2961) --- changelog.d/unreleased/2961.fixed.md | 16 +++ .../LanguageReferenceExtractionSupport.cs | 117 ++++++++++++++++++ .../ReferenceExtractorTests.cs | 15 +++ 3 files changed, 148 insertions(+) create mode 100644 changelog.d/unreleased/2961.fixed.md diff --git a/changelog.d/unreleased/2961.fixed.md b/changelog.d/unreleased/2961.fixed.md new file mode 100644 index 0000000000..74f24cc779 --- /dev/null +++ b/changelog.d/unreleased/2961.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2961 +affected: + - src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs + - tests/CodeIndex.Tests/ReferenceExtractorTests.cs +--- + +## English + +- **C `va_arg` typedef references are now parsed from balanced arguments (#2961)** — `va_arg(select_args(primary, fallback), widget_t)` now records the requested typedef even when the `va_list` expression contains nested commas. + +## 日本語 + +- **C の `va_arg` typedef 参照を balanced argument から解析するようになりました (#2961)** — `va_arg(select_args(primary, fallback), widget_t)` のように `va_list` 式が入れ子のカンマを含む場合でも、要求された typedef を記録します。 diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs index ce58e55643..fc59281f73 100644 --- a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs @@ -150,6 +150,11 @@ internal static class LanguageReferenceExtractionSupport private static readonly Regex CTaggedVaArgTypeRegex = new( @"\b(?:va_arg|__builtin_va_arg)\s*\(\s*[^,;{}]+,\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly string[] CVaArgFunctionNames = + { + "va_arg", + "__builtin_va_arg", + }; private static readonly Regex CppTypeOperandOperatorRegex = new( @"\b(?:sizeof|alignof)\s*\(\s*(?(?:(?:const|volatile|typename|class|struct|enum)\s+)*(?:[A-Z_]\w*|[A-Za-z_]\w*\s*::\s*[A-Za-z_]\w*)(?:\s*<[^;{}]+>)?(?:\s*[*&])*)\s*\)", RegexOptions.Compiled | RegexOptions.CultureInvariant); @@ -1606,6 +1611,8 @@ private static void EmitCppTypeReferences( var group = match.Groups["type"]; ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); } + + EmitCVaArgTypeOperandReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, language); } foreach (Match match in CppTypeOperandOperatorRegex.Matches(preparedLine)) @@ -1773,6 +1780,116 @@ private static void EmitCppTypeReferences( } } + private static void EmitCVaArgTypeOperandReferences( + string line, + List references, + HashSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + string language) + { + foreach (var functionName in CVaArgFunctionNames) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var functionIndex = line.IndexOf(functionName, searchStart, StringComparison.Ordinal); + if (functionIndex < 0) + break; + + searchStart = functionIndex + functionName.Length; + if (!IsIdentifierAt(line, functionIndex, functionName)) + continue; + + var open = SkipWhitespace(line, functionIndex + functionName.Length); + if (open >= line.Length || line[open] != '(') + continue; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); + if (close < 0) + continue; + + var argumentList = line.Substring(open + 1, close - open - 1); + var arguments = ReferenceExtractor.SplitTopLevelCommaSpans(argumentList); + if (arguments.Count < 2) + continue; + + var typeArgument = arguments[1]; + if (typeArgument.Length <= 0) + continue; + + var rawType = argumentList.Substring(typeArgument.Start, typeArgument.Length); + var expression = rawType.Trim(); + if (expression.Length == 0 || !LooksLikeCVaArgTypeOperand(expression)) + continue; + + var trimStart = rawType.IndexOf(expression, StringComparison.Ordinal); + var absoluteStart = open + 1 + typeArgument.Start + Math.Max(0, trimStart); + ReferenceExtractor.AddTypeExpressionSegments( + references, + seen, + fileId, + expression, + absoluteStart, + context, + lineNumber, + resolveContainerForColumn(absoluteStart), + language); + } + } + } + + private static bool LooksLikeCVaArgTypeOperand(string expression) + { + var cursor = SkipLeadingCTypeQualifiers(expression, 0); + if (cursor >= expression.Length) + return false; + + foreach (var keyword in new[] { "struct", "enum", "union" }) + { + if (StartsWithKeyword(expression, cursor, keyword)) + { + cursor = SkipWhitespace(expression, cursor + keyword.Length); + return cursor < expression.Length && IsIdentifierStart(expression[cursor]); + } + } + + if (!IsIdentifierStart(expression[cursor])) + return false; + + var nameStart = cursor; + cursor++; + while (cursor < expression.Length && IsSimpleIdentifierPart(expression[cursor])) + cursor++; + + return expression.AsSpan(nameStart, cursor - nameStart).EndsWith("_t", StringComparison.Ordinal); + } + + private static int SkipLeadingCTypeQualifiers(string expression, int cursor) + { + while (cursor < expression.Length) + { + cursor = SkipWhitespace(expression, cursor); + var next = cursor; + if (StartsWithKeyword(expression, cursor, "const")) + next += "const".Length; + else if (StartsWithKeyword(expression, cursor, "volatile")) + next += "volatile".Length; + else if (StartsWithKeyword(expression, cursor, "restrict")) + next += "restrict".Length; + else if (StartsWithKeyword(expression, cursor, "_Atomic")) + next += "_Atomic".Length; + else + return cursor; + + cursor = next; + } + + return cursor; + } + private static void EmitGoTypeReferences( string preparedLine, string originalLine, diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index 260e3c7602..0fa3902e9a 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -5114,6 +5114,21 @@ void configure(void) { Assert.Contains(references, r => r.SymbolName == "widget_t" && r.ReferenceKind == "type_reference"); } + [Fact] + public void Extract_CTypedefVaArgOperands_CapturesTypeWhenVaListExpressionContainsComma() + { + const string content = """ + void configure(void) { + consume(va_arg(select_args(primary, fallback), widget_t)); + } + """; + + var symbols = SymbolExtractor.Extract(1, "c", content); + var references = ReferenceExtractor.Extract(1, "c", content, symbols); + + Assert.Contains(references, r => r.SymbolName == "widget_t" && r.ReferenceKind == "type_reference"); + } + [Fact] public void Extract_CTaggedSizeofOperands_CapturesTagTypeReferences() { From bd7e0ad8961ab8b762c240f4eb73c083ff9f0a60 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 05:29:25 +0900 Subject: [PATCH 2/3] Fix C va_arg argument splitting (#2961) --- .../LanguageReferenceExtractionSupport.cs | 46 ++++++++++++++++++- .../ReferenceExtractorTests.cs | 15 ++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs index fc59281f73..1167ea1358 100644 --- a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs @@ -1812,7 +1812,7 @@ private static void EmitCVaArgTypeOperandReferences( continue; var argumentList = line.Substring(open + 1, close - open - 1); - var arguments = ReferenceExtractor.SplitTopLevelCommaSpans(argumentList); + var arguments = SplitTopLevelCArgumentSpans(argumentList); if (arguments.Count < 2) continue; @@ -1841,6 +1841,50 @@ private static void EmitCVaArgTypeOperandReferences( } } + private static List<(int Start, int Length)> SplitTopLevelCArgumentSpans(string text) + { + var spans = new List<(int Start, int Length)>(); + int parenDepth = 0; + int squareDepth = 0; + int braceDepth = 0; + int start = 0; + + for (int i = 0; i < text.Length; i++) + { + switch (text[i]) + { + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) + squareDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) + braceDepth--; + break; + case ',' when parenDepth == 0 && squareDepth == 0 && braceDepth == 0: + spans.Add((start, i - start)); + start = i + 1; + break; + } + } + + spans.Add((start, text.Length - start)); + return spans; + } + private static bool LooksLikeCVaArgTypeOperand(string expression) { var cursor = SkipLeadingCTypeQualifiers(expression, 0); diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index 0fa3902e9a..2910d6e4af 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -5129,6 +5129,21 @@ void configure(void) { Assert.Contains(references, r => r.SymbolName == "widget_t" && r.ReferenceKind == "type_reference"); } + [Fact] + public void Extract_CTypedefVaArgOperands_CapturesTypeWhenVaListExpressionContainsComparisonAndComma() + { + const string content = """ + void configure(void) { + consume(va_arg(select_args(primary < fallback, fallback), widget_t)); + } + """; + + var symbols = SymbolExtractor.Extract(1, "c", content); + var references = ReferenceExtractor.Extract(1, "c", content, symbols); + + Assert.Contains(references, r => r.SymbolName == "widget_t" && r.ReferenceKind == "type_reference"); + } + [Fact] public void Extract_CTaggedSizeofOperands_CapturesTagTypeReferences() { From 5c80809469e529153386c5baf038a4a0e40f8f57 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 05:50:43 +0900 Subject: [PATCH 3/3] Restore origin/main scope after merge --- changelog.d/unreleased/2860.security.md | 17 +++ changelog.d/unreleased/2949.fixed.md | 16 +++ changelog.d/unreleased/2951.fixed.md | 16 +++ changelog.d/unreleased/2953.fixed.md | 15 +++ src/CodeIndex/Cli/DbPathResolver.cs | 9 +- src/CodeIndex/Indexer/BoundedRegex.cs | 3 +- src/CodeIndex/Mcp/McpServer.cs | 121 ++++++++++++++++++- src/CodeIndex/Mcp/McpToolHandlers.cs | 11 +- tests/CodeIndex.Tests/BoundedRegexTests.cs | 15 +++ tests/CodeIndex.Tests/DbPathResolverTests.cs | 68 ++++++++++- tests/CodeIndex.Tests/McpServerTests.cs | 34 ++++++ 11 files changed, 309 insertions(+), 16 deletions(-) create mode 100644 changelog.d/unreleased/2860.security.md create mode 100644 changelog.d/unreleased/2949.fixed.md create mode 100644 changelog.d/unreleased/2951.fixed.md create mode 100644 changelog.d/unreleased/2953.fixed.md create mode 100644 tests/CodeIndex.Tests/BoundedRegexTests.cs diff --git a/changelog.d/unreleased/2860.security.md b/changelog.d/unreleased/2860.security.md new file mode 100644 index 0000000000..e2bef6ae6c --- /dev/null +++ b/changelog.d/unreleased/2860.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2860 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP response byte limits now stop JSON serialization before full string materialization (#2860)** — oversized MCP responses are measured with a bounded UTF-8 JSON writer so the server can reject them as soon as the configured byte cap is crossed, with response-too-large errors marking whether the reported byte count is exact. + +## 日本語 + +- **MCP response byte limit が JSON 全体の文字列化前に serialization を停止するようになりました (#2860)** — oversized な MCP response は上限付き UTF-8 JSON writer で測定し、設定された byte cap を超えた時点で拒否し、response-too-large error では報告 byte count が exact かどうかも示すようにしました。 diff --git a/changelog.d/unreleased/2949.fixed.md b/changelog.d/unreleased/2949.fixed.md new file mode 100644 index 0000000000..4845a74c66 --- /dev/null +++ b/changelog.d/unreleased/2949.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2949 +affected: + - src/CodeIndex/Indexer/BoundedRegex.cs + - tests/CodeIndex.Tests/BoundedRegexTests.cs +--- + +## English + +- **SQL reference extraction is less likely to drop matches under full-suite CPU contention (#2949)** - bounded regex matching now keeps a larger but still finite default timeout, reducing intermittent missed SQL references during heavily loaded test and indexing runs. + +## 日本語 + +- **full-suite の CPU 競合下で SQL reference 抽出が match を落としにくくなりました (#2949)** - bounded regex matching の既定 timeout を、有限のまま余裕を持たせた値に変更し、高負荷なテスト実行や index 実行中に SQL reference が断続的に欠ける問題を減らしました。 diff --git a/changelog.d/unreleased/2951.fixed.md b/changelog.d/unreleased/2951.fixed.md new file mode 100644 index 0000000000..3ec9306675 --- /dev/null +++ b/changelog.d/unreleased/2951.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2951 +affected: + - src/CodeIndex/Cli/DbPathResolver.cs + - tests/CodeIndex.Tests/DbPathResolverTests.cs +--- + +## English + +- **DbPathResolver query tests now isolate active workspace state (#2951)** — internal query data-dir resolution tests can inject active-workspace state explicitly, so ambient user config no longer redirects the temporary project roots under test. + +## 日本語 + +- **DbPathResolver の query test が active workspace state を隔離するようになりました (#2951)** — internal query data-dir resolution test が active-workspace state を明示的に注入できるようになり、ambient なユーザー設定でテスト用の一時 project root が別 DB に向かなくなりました。 diff --git a/changelog.d/unreleased/2953.fixed.md b/changelog.d/unreleased/2953.fixed.md new file mode 100644 index 0000000000..f00be3719c --- /dev/null +++ b/changelog.d/unreleased/2953.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 2953 +affected: + - tests/CodeIndex.Tests/DbPathResolverTests.cs +--- + +## English + +- **DbPathResolver query data-dir tests no longer depend on the active workspace state (#2953)** — `ResolveDataDirForQuery` tests now isolate `CDIDX_ACTIVE_WORKSPACE` and `XDG_CONFIG_HOME`, so developer-local active workspace configuration cannot redirect expected test database paths. + +## 日本語 + +- **DbPathResolver の query data-dir テストが active workspace 状態に依存しないようになりました (#2953)** — `ResolveDataDirForQuery` テストで `CDIDX_ACTIVE_WORKSPACE` と `XDG_CONFIG_HOME` を隔離することで、開発者ローカルの active workspace 設定が期待するテスト用 DB パスを上書きしないようにしました。 diff --git a/src/CodeIndex/Cli/DbPathResolver.cs b/src/CodeIndex/Cli/DbPathResolver.cs index 92ccc6531e..1124e54045 100644 --- a/src/CodeIndex/Cli/DbPathResolver.cs +++ b/src/CodeIndex/Cli/DbPathResolver.cs @@ -60,7 +60,12 @@ internal static DbPathResolution ResolveDataDir(string workspacePath, string? ex return BuildDataDirResolution(Path.Combine(fullWorkspacePath, ".cdidx"), DataDirSourceWorkspace); } - internal static DbPathResolution ResolveDataDirForQuery(string workspacePath, string? explicitDataDir, string? environmentDataDir, string? xdgDataHome) + internal static DbPathResolution ResolveDataDirForQuery( + string workspacePath, + string? explicitDataDir, + string? environmentDataDir, + string? xdgDataHome, + Func? activeWorkspaceLoader = null) { var fullWorkspacePath = Path.GetFullPath(workspacePath); if (!string.IsNullOrWhiteSpace(explicitDataDir)) @@ -69,7 +74,7 @@ internal static DbPathResolution ResolveDataDirForQuery(string workspacePath, st if (!string.IsNullOrWhiteSpace(environmentDataDir)) return BuildDataDirResolution(environmentDataDir, DataDirSourceEnv); - var active = ActiveWorkspace.Load(); + var active = (activeWorkspaceLoader ?? ActiveWorkspace.Load)(); if (active != null) return new DbPathResolution(active.DbPath, Path.GetDirectoryName(active.DbPath), DataDirSourceActiveWorkspace); diff --git a/src/CodeIndex/Indexer/BoundedRegex.cs b/src/CodeIndex/Indexer/BoundedRegex.cs index daec250045..325cc7a37f 100644 --- a/src/CodeIndex/Indexer/BoundedRegex.cs +++ b/src/CodeIndex/Indexer/BoundedRegex.cs @@ -6,7 +6,8 @@ namespace CodeIndex.Indexer; internal sealed class BoundedRegex : BclRegex { - internal static readonly TimeSpan DefaultMatchTimeout = TimeSpan.FromMilliseconds(250); + // Keep regex matches bounded, but leave enough scheduler headroom for full-suite CI contention. + internal static readonly TimeSpan DefaultMatchTimeout = TimeSpan.FromSeconds(1); public BoundedRegex(string pattern) : base(pattern, RegexOptions.None, DefaultMatchTimeout) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index fdadbee598..53efee1e2c 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -32,6 +32,7 @@ public partial class McpServer : IDisposable private readonly string _version; private readonly JsonSerializerOptions _jsonOptions; private readonly Func _serializeResponse; + private readonly bool _usesDefaultResponseSerializer; private readonly IMcpAuthenticator _authenticator; private readonly McpToolFilter _toolFilter; private readonly TimeProvider _timeProvider; @@ -262,6 +263,7 @@ internal McpServer(string dbPath, string version, bool dbPathExplicit, Func node.ToJsonString(_jsonOptions)); _authenticator = authenticator ?? LocalStdioAuthenticator.Instance; _toolFilter = toolFilter ?? McpToolFilter.FromEnvironment(); @@ -1042,9 +1044,17 @@ private string SerializeResponseOrFallback(JsonNode response, bool hasId, JsonNo { try { + var responseLimit = GetMaxResponseBytes(); + if (_usesDefaultResponseSerializer) + { + if (!TrySerializeJsonNodeWithinByteLimit(response, _jsonOptions, responseLimit, captureSerialized: true, out var boundedSerialized, out var boundedResponseBytes)) + return CreateResponseTooLargeError(hasId, id, boundedResponseBytes, responseLimit, actualBytesExact: false).ToJsonString(_jsonOptions); + + return boundedSerialized!; + } + var serialized = _serializeResponse(response); var responseBytes = Encoding.UTF8.GetByteCount(serialized); - var responseLimit = GetMaxResponseBytes(); if (responseBytes <= responseLimit) return serialized; @@ -2991,7 +3001,7 @@ private static JsonObject CreateCancelledResponse(JsonNode? id) /// Create a tool result response (MCP format). /// ツール結果レスポンスを作成(MCP形式)。 /// - private static JsonObject CreateToolResult(JsonNode? id, string text, JsonNode? structuredContent = null, string? mimeType = null) + private JsonObject CreateToolResult(JsonNode? id, string text, JsonNode? structuredContent = null, string? mimeType = null) { mimeType ??= structuredContent is null ? "text/plain" : "application/json"; var result = new JsonObject @@ -3009,15 +3019,113 @@ private static JsonObject CreateToolResult(JsonNode? id, string text, JsonNode? if (structuredContent != null) result["structuredContent"] = structuredContent; var response = CreateSuccessResponse(true, id, result); - var responseBytes = Encoding.UTF8.GetByteCount(response.ToJsonString()); var responseLimit = GetMaxResponseBytes(); - if (responseBytes <= responseLimit) + if (TryMeasureJsonUtf8BytesWithinLimit(response, _jsonOptions, responseLimit, out var responseBytes)) return response; - return CreateResponseTooLargeError(true, id, responseBytes, responseLimit); + return CreateResponseTooLargeError(true, id, responseBytes, responseLimit, actualBytesExact: false); + } + + internal bool TrySerializeJsonNodeWithinByteLimitForTests(JsonNode node, int maxBytes, out string? serialized, out int bytesWritten) + => TrySerializeJsonNodeWithinByteLimit(node, _jsonOptions, maxBytes, captureSerialized: true, out serialized, out bytesWritten); + + private static bool TryMeasureJsonUtf8BytesWithinLimit(JsonNode node, JsonSerializerOptions options, int maxBytes, out int bytesWritten) + => TrySerializeJsonNodeWithinByteLimit(node, options, maxBytes, captureSerialized: false, out _, out bytesWritten); + + private static bool TrySerializeJsonNodeWithinByteLimit(JsonNode node, JsonSerializerOptions options, int maxBytes, bool captureSerialized, out string? serialized, out int bytesWritten) + { + if (maxBytes < 0) + throw new ArgumentOutOfRangeException(nameof(maxBytes), maxBytes, "JSON byte limit must be non-negative."); + + serialized = null; + using var stream = new BoundedJsonUtf8Stream(maxBytes, captureSerialized); + var writerOptions = new JsonWriterOptions + { + Encoder = options.Encoder, + Indented = options.WriteIndented, + }; + + try + { + using var writer = new Utf8JsonWriter(stream, writerOptions); + node.WriteTo(writer, options); + writer.Flush(); + bytesWritten = stream.BytesWritten; + serialized = stream.GetCapturedString(); + return true; + } + catch (JsonResponseByteLimitExceededException ex) + { + bytesWritten = ex.BytesWritten; + return false; + } + } + + private sealed class JsonResponseByteLimitExceededException(int bytesWritten) : Exception + { + public int BytesWritten { get; } = bytesWritten; + } + + private sealed class BoundedJsonUtf8Stream(int maxBytes, bool captureSerialized) : Stream + { + private readonly MemoryStream? _buffer = captureSerialized ? new MemoryStream(Math.Min(Math.Max(maxBytes, 0), 16 * 1024)) : null; + + public int BytesWritten { get; private set; } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public string? GetCapturedString() + { + if (_buffer is null) + return null; + return Encoding.UTF8.GetString(_buffer.GetBuffer(), 0, (int)_buffer.Length); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => Write(buffer.AsSpan(offset, count)); + + public override void Write(ReadOnlySpan buffer) + { + if (buffer.Length == 0) + return; + + var remaining = maxBytes - BytesWritten; + if (remaining < buffer.Length) + { + if (remaining > 0) + _buffer?.Write(buffer[..remaining]); + BytesWritten = maxBytes == int.MaxValue ? int.MaxValue : maxBytes + 1; + throw new JsonResponseByteLimitExceededException(BytesWritten); + } + + _buffer?.Write(buffer); + BytesWritten += buffer.Length; + } } - private static JsonObject CreateResponseTooLargeError(bool hasId, JsonNode? id, int responseBytes, int responseLimit) + private static JsonObject CreateResponseTooLargeError(bool hasId, JsonNode? id, int responseBytes, int responseLimit, bool actualBytesExact = true) { return CreateErrorResponse( hasId: hasId, @@ -3032,6 +3140,7 @@ private static JsonObject CreateResponseTooLargeError(bool hasId, JsonNode? id, ["reason"] = "response_too_large", ["limit_bytes"] = responseLimit, ["actual_bytes"] = responseBytes, + ["actual_bytes_exact"] = actualBytesExact, }); } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 57236f7b3d..dabb0bfbb4 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2792,7 +2792,7 @@ string BuildSummary() } summary = BuildSummary(); - estimatedResponseBytes = EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload.DeepClone())); + estimatedResponseBytes = EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload.DeepClone()), responseByteLimit); if (estimatedResponseBytes <= responseByteLimit) break; if (resultsArray.Count > 0) @@ -2827,8 +2827,11 @@ private static int GetBatchQueryResponseByteLimit() MaxBatchQueryResponseByteLimit, "MCP batch_query response byte limit"); - private int EstimateJsonUtf8Bytes(JsonNode node) => - Encoding.UTF8.GetByteCount(node.ToJsonString(_jsonOptions)); + private int EstimateJsonUtf8Bytes(JsonNode node, int maxBytes = int.MaxValue) + { + _ = TryMeasureJsonUtf8BytesWithinLimit(node, _jsonOptions, maxBytes, out var bytesWritten); + return bytesWritten; + } private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submittedCount, int successCount, int failureCount, string failureScope, int? cascadeStartedAtIndex, int responseByteLimit, JsonArray resultsArray, bool truncated, JsonArray truncatedQueries) @@ -2861,7 +2864,7 @@ private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submitt payload["truncated_queries"] = truncatedQueries.DeepClone(); } - return EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload)); + return EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload), responseByteLimit); } private static string GetBatchFailureScope(int submittedCount, int successCount, int failureCount, int? cascadeStartedAtIndex) diff --git a/tests/CodeIndex.Tests/BoundedRegexTests.cs b/tests/CodeIndex.Tests/BoundedRegexTests.cs new file mode 100644 index 0000000000..6b1ed8026d --- /dev/null +++ b/tests/CodeIndex.Tests/BoundedRegexTests.cs @@ -0,0 +1,15 @@ +using CodeIndex.Indexer; + +namespace CodeIndex.Tests; + +public sealed class BoundedRegexTests +{ + [Fact] + public void DefaultMatchTimeout_KeepsBoundedMatchesFromTimingOutUnderNormalSchedulerContention() + { + Assert.InRange( + BoundedRegex.DefaultMatchTimeout, + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(5)); + } +} diff --git a/tests/CodeIndex.Tests/DbPathResolverTests.cs b/tests/CodeIndex.Tests/DbPathResolverTests.cs index 7e324c1629..b81156b4ae 100644 --- a/tests/CodeIndex.Tests/DbPathResolverTests.cs +++ b/tests/CodeIndex.Tests/DbPathResolverTests.cs @@ -81,15 +81,22 @@ public void ResolveDataDir_UsesStableXdgWorkspaceHashBeforeWorkspaceDefault() public void ResolveDataDirForQuery_WithXdgPrefersAncestorWorkspaceDataDir() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_xdg_root_db"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_query_xdg_config"); var xdgDir = Path.Combine(Path.GetTempPath(), $"cdidx_xdg_dir_{Guid.NewGuid():N}"); try { + using var env = IsolateActiveWorkspace(configHome); var child = Path.Combine(projectRoot, "src", "App"); Directory.CreateDirectory(child); var indexedRootResolution = DbPathResolver.ResolveDataDir(projectRoot, explicitDataDir: null, environmentDataDir: null, xdgDataHome: xdgDir); Directory.CreateDirectory(indexedRootResolution.DataDir!); - var resolved = DbPathResolver.ResolveDataDirForQuery(child, explicitDataDir: null, environmentDataDir: null, xdgDataHome: xdgDir); + var resolved = DbPathResolver.ResolveDataDirForQuery( + child, + explicitDataDir: null, + environmentDataDir: null, + xdgDataHome: xdgDir, + activeWorkspaceLoader: () => null); Assert.Equal(indexedRootResolution.DbPath, resolved.DbPath); Assert.Equal(indexedRootResolution.DataDir, resolved.DataDir); @@ -98,6 +105,7 @@ public void ResolveDataDirForQuery_WithXdgPrefersAncestorWorkspaceDataDir() finally { TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(configHome); TestProjectHelper.DeleteDirectory(xdgDir); } } @@ -106,14 +114,21 @@ public void ResolveDataDirForQuery_WithXdgPrefersAncestorWorkspaceDataDir() public void ResolveDataDirForQuery_PrefersOutermostAncestorCdidx() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_root_db"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_query_root_config"); try { + using var env = IsolateActiveWorkspace(configHome); var child = Path.Combine(projectRoot, "src", "App"); Directory.CreateDirectory(child); Directory.CreateDirectory(Path.Combine(projectRoot, ".cdidx")); Directory.CreateDirectory(Path.Combine(projectRoot, "src", ".cdidx")); - var resolved = DbPathResolver.ResolveDataDirForQuery(child, explicitDataDir: null, environmentDataDir: null, xdgDataHome: null); + var resolved = DbPathResolver.ResolveDataDirForQuery( + child, + explicitDataDir: null, + environmentDataDir: null, + xdgDataHome: null, + activeWorkspaceLoader: () => null); Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), resolved.DbPath); Assert.Equal(DbPathResolver.DataDirSourceWorkspace, resolved.DataDirSource); @@ -121,6 +136,37 @@ public void ResolveDataDirForQuery_PrefersOutermostAncestorCdidx() finally { TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(configHome); + } + } + + [Fact] + public void ResolveDataDirForQuery_UsesInjectedActiveWorkspaceBeforeAncestorCdidx() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_active_workspace_project"); + var activeRoot = TestProjectHelper.CreateTempProject("cdidx_query_active_workspace_state"); + var activeDb = Path.Combine(activeRoot, ".cdidx", "codeindex.db"); + try + { + var child = Path.Combine(projectRoot, "src", "App"); + Directory.CreateDirectory(child); + Directory.CreateDirectory(Path.Combine(projectRoot, ".cdidx")); + + var resolved = DbPathResolver.ResolveDataDirForQuery( + child, + explicitDataDir: null, + environmentDataDir: null, + xdgDataHome: null, + activeWorkspaceLoader: () => new ActiveWorkspaceState("test", activeRoot, activeDb)); + + Assert.Equal(Path.GetFullPath(activeDb), resolved.DbPath); + Assert.Equal(Path.GetDirectoryName(Path.GetFullPath(activeDb)), resolved.DataDir); + Assert.Equal(DbPathResolver.DataDirSourceActiveWorkspace, resolved.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(activeRoot); } } @@ -128,12 +174,19 @@ public void ResolveDataDirForQuery_PrefersOutermostAncestorCdidx() public void ResolveDataDirForQuery_FallsBackToCurrentDirectoryWhenNoAncestorCdidxExists() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_no_root_db"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_query_no_root_config"); try { + using var env = IsolateActiveWorkspace(configHome); var child = Path.Combine(projectRoot, "src", "App"); Directory.CreateDirectory(child); - var resolved = DbPathResolver.ResolveDataDirForQuery(child, explicitDataDir: null, environmentDataDir: null, xdgDataHome: null); + var resolved = DbPathResolver.ResolveDataDirForQuery( + child, + explicitDataDir: null, + environmentDataDir: null, + xdgDataHome: null, + activeWorkspaceLoader: () => null); Assert.Equal(Path.Combine(child, ".cdidx", "codeindex.db"), resolved.DbPath); Assert.Equal(DbPathResolver.DataDirSourceWorkspace, resolved.DataDirSource); @@ -141,6 +194,7 @@ public void ResolveDataDirForQuery_FallsBackToCurrentDirectoryWhenNoAncestorCdid finally { TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(configHome); } } @@ -700,4 +754,12 @@ public void ResolveProjectRootForQuery_ReturnsNullForExplicitDbWithoutMetadata() TestProjectHelper.DeleteFile(dbPath); } } + + private static EnvironmentVariableScope IsolateActiveWorkspace(string configHome) + { + var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + env.Set(ActiveWorkspace.EnvironmentVariable, null); + env.Set("XDG_CONFIG_HOME", configHome); + return env; + } } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 2448803995..a967b1a9c0 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5318,6 +5318,40 @@ public void ToolsCall_ResponseOverByteLimit_ReturnsStructuredError() Assert.Equal("response_too_large", response["error"]!["data"]!["reason"]!.GetValue()); Assert.Equal(256, response["error"]!["data"]!["limit_bytes"]!.GetValue()); Assert.True(response["error"]!["data"]!["actual_bytes"]!.GetValue() > 256); + Assert.False(response["error"]!["data"]!["actual_bytes_exact"]!.GetValue()); + } + + [Fact] + public void ResponseLimitSerializer_StopsBeforeFullStringMaterialization_Issue2860() + { + var payload = new JsonObject + { + ["value"] = new string('x', 10_000), + }; + + var withinLimit = _server.TrySerializeJsonNodeWithinByteLimitForTests(payload, 256, out var serialized, out var bytesWritten); + + Assert.False(withinLimit); + Assert.Null(serialized); + Assert.True(bytesWritten > 256); + Assert.True(bytesWritten < 10_000); + } + + [Fact] + public void ResponseLimitSerializer_ReturnsCapturedJsonWhenWithinLimit_Issue2860() + { + var payload = new JsonObject + { + ["value"] = "ok", + }; + + var withinLimit = _server.TrySerializeJsonNodeWithinByteLimitForTests(payload, 256, out var serialized, out var bytesWritten); + + Assert.True(withinLimit); + Assert.NotNull(serialized); + Assert.Equal(Encoding.UTF8.GetByteCount(serialized), bytesWritten); + using var parsed = JsonDocument.Parse(serialized); + Assert.Equal("ok", parsed.RootElement.GetProperty("value").GetString()); } [Fact]