diff --git a/changelog.d/unreleased/3658.fixed.md b/changelog.d/unreleased/3658.fixed.md new file mode 100644 index 0000000000..a75a5b3a5c --- /dev/null +++ b/changelog.d/unreleased/3658.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 3658 +affected: + - src/CodeIndex/Cli/SuggestionStore.cs + - src/CodeIndex/Lsp/LspServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs +--- + +## English + +- Route caller cancellation through MCP status update checks, add a cancellable suggestion submission overload, and document compatibility no-token LSP/suggestion wrappers. + +## 日本語 + +- MCP status update check に caller cancellation を渡し、キャンセル可能な suggestion submission overload を追加し、LSP / suggestion の no-token 互換 wrapper を明記しました。 diff --git a/changelog.d/unreleased/3705.fixed.md b/changelog.d/unreleased/3705.fixed.md new file mode 100644 index 0000000000..443a61205b --- /dev/null +++ b/changelog.d/unreleased/3705.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3705 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs + - src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - tests/CodeIndex.Tests/IndexCommandRunnerHeartbeatTests.cs +--- + +## English + +- Route JSON index heartbeat tasks and HTTP MCP request-handler tasks through observed background work while keeping expected cancellation quiet and preserving handler-slot cleanup during shutdown. + +## 日本語 + +- JSON index heartbeat タスクと HTTP MCP request-handler タスクを監視対象の background work に通し、期待されるキャンセルでは診断を出さず、shutdown 中も handler slot cleanup を保つようにしました。 diff --git a/changelog.d/unreleased/3743.fixed.md b/changelog.d/unreleased/3743.fixed.md new file mode 100644 index 0000000000..6a4f450a69 --- /dev/null +++ b/changelog.d/unreleased/3743.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3743 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs +--- + +## English + +- Avoid repeated full JSON serialization passes while trimming oversized LSP document-symbol responses. + +## 日本語 + +- サイズ超過した LSP document-symbol response を trim する際、root 全体の JSON serialization を繰り返さないようにしました。 diff --git a/changelog.d/unreleased/3758.fixed.md b/changelog.d/unreleased/3758.fixed.md new file mode 100644 index 0000000000..909a985676 --- /dev/null +++ b/changelog.d/unreleased/3758.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3758 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs +--- + +## English + +- Cap LSP document-symbol materialization before sorting and emit diagnostics when candidate materialization is truncated. + +## 日本語 + +- LSP document-symbol の候補 materialization を sort 前に制限し、候補が truncation された場合は diagnostics を出すようにしました。 diff --git a/changelog.d/unreleased/3760.fixed.md b/changelog.d/unreleased/3760.fixed.md new file mode 100644 index 0000000000..b108c27014 --- /dev/null +++ b/changelog.d/unreleased/3760.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3760 +affected: + - src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs + - tests/CodeIndex.Tests/BackgroundTaskObserverTests.cs +--- + +## English + +- Honor shutdown cancellation when scheduling observed background work. + +## 日本語 + +- 監視対象のバックグラウンド処理をスケジュールする際に shutdown cancellation を反映するようにしました。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 6acc1cfd91..fd242f6027 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -672,33 +672,12 @@ private static (CancellationTokenSource Cts, Task Task)? StartFullScanJsonPhaseH string phase, Func? detailProvider = null) { - if (!options.Json || options.Quiet) - return null; - - var cts = new CancellationTokenSource(); - var token = cts.Token; - var task = Task.Run(async () => - { - while (!token.IsCancellationRequested) - { - try - { - await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } - - if (token.IsCancellationRequested) - break; - - var detail = detailProvider?.Invoke(); - var suffix = string.IsNullOrWhiteSpace(detail) ? string.Empty : $": {detail}"; - ConsoleUi.TryWriteErrorLine($"cdidx: still {phase}{suffix}..."); - } - }, token); - return (cts, task); + return StartObservedJsonPhaseHeartbeat( + options.Json && !options.Quiet, + "cdidx-index", + phase, + ConsoleUi.TryWriteErrorLine, + detailProvider); } private static void StopFullScanJsonPhaseHeartbeat((CancellationTokenSource Cts, Task Task)? heartbeat) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs b/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs index 17f00941b9..2ea49e55de 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using CodeIndex.Diagnostics; namespace CodeIndex.Cli; @@ -122,33 +123,64 @@ private static (CancellationTokenSource Cts, Task Task)? StartIndexJsonPhaseHear string phase, Func? detailProvider = null) { - if (!options.Json || options.Quiet) + return StartObservedJsonPhaseHeartbeat( + options.Json && !options.Quiet, + "cdidx-index", + phase, + ConsoleUi.TryWriteErrorLine, + detailProvider); + } + + internal static (CancellationTokenSource Cts, Task Task)? StartObservedJsonPhaseHeartbeat( + bool enabled, + string component, + string phase, + Action messageWriter, + Func? detailProvider = null, + TimeSpan? interval = null, + Action? warningWriter = null) + { + if (!enabled) return null; + ArgumentNullException.ThrowIfNull(messageWriter); + var cts = new CancellationTokenSource(); - var token = cts.Token; - var task = Task.Run(async () => + var heartbeatInterval = interval ?? TimeSpan.FromSeconds(5); + var task = BackgroundTaskObserver.Run( + token => RunObservedJsonPhaseHeartbeatLoop(phase, messageWriter, detailProvider, heartbeatInterval, token), + component, + $"{phase} heartbeat", + cts.Token, + warningWriter); + return (cts, task); + } + + private static async Task RunObservedJsonPhaseHeartbeatLoop( + string phase, + Action messageWriter, + Func? detailProvider, + TimeSpan interval, + CancellationToken token) + { + while (!token.IsCancellationRequested) { - while (!token.IsCancellationRequested) + try { - try - { - await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } + await Task.Delay(interval, token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + break; + } - if (token.IsCancellationRequested) - break; + if (token.IsCancellationRequested) + break; - var detail = detailProvider?.Invoke(); - var suffix = string.IsNullOrWhiteSpace(detail) ? string.Empty : $": {detail}"; - CommandErrorWriter.WriteStderr($"cdidx: still {phase}{suffix}..."); - } - }, token); - return (cts, task); + var detail = detailProvider?.Invoke(); + var suffix = string.IsNullOrWhiteSpace(detail) ? string.Empty : $": {detail}"; + messageWriter($"cdidx: still {phase}{suffix}..."); + } } private static void StopIndexJsonPhaseHeartbeat((CancellationTokenSource Cts, Task Task)? heartbeat) diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index 575192326f..a5d0b161c7 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -206,9 +206,14 @@ public record SubmitAttemptResult(string? IssueUrl, string? Error, DateTime? Nex /// Add a suggestion under the file lock, then attempt GitHub submission outside the lock. /// The store reserves the attempt before releasing the lock so concurrent callers do not /// also submit the same unsubmitted duplicate while the remote API is slow. + /// This compatibility wrapper has no caller cancellation; use the overload that accepts + /// when cancellation should be observed. /// 提案をファイルロック内で追加し、その後 GitHub 送信はロック外で試行する。 /// remote API が遅い間に並行呼び出しが同じ未送信重複を送信しないよう、 /// ロック解放前に送信試行を予約する。 + /// この互換 wrapper は caller cancellation を持たない。キャンセルを監視する場合は + /// + /// を使う。 /// /// The suggestion to add / 追加する提案 /// @@ -218,13 +223,19 @@ public record SubmitAttemptResult(string? IssueUrl, string? Error, DateTime? Nex /// 未送信の重複)にのみロック外で呼ばれる。成功時は Issue URL を返す。 /// public AddAndSubmitResult TryAddAndSubmit(SuggestionRecord record, Func? submitToGitHub) + => TryAddAndSubmit(record, submitToGitHub, CancellationToken.None); + + public AddAndSubmitResult TryAddAndSubmit( + SuggestionRecord record, + Func? submitToGitHub, + CancellationToken cancellationToken) { return TryAddAndSubmitAsync( record, submitToGitHub == null ? null : (r, _) => Task.FromResult(submitToGitHub(r)), - CancellationToken.None).GetAwaiter().GetResult(); + cancellationToken).GetAwaiter().GetResult(); } public async Task TryAddAndSubmitAsync( diff --git a/src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs b/src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs index 08649df8c3..921a79e5fb 100644 --- a/src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs +++ b/src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs @@ -15,7 +15,7 @@ internal static Task Run( { ArgumentNullException.ThrowIfNull(action); - return Observe(Task.Run(action, CancellationToken.None), component, operation, warningWriter); + return Run(_ => action(), component, operation, CancellationToken.None, warningWriter); } internal static Task Run( @@ -27,7 +27,7 @@ internal static Task Run( { ArgumentNullException.ThrowIfNull(action); - return Observe(Task.Run(() => action(cancellationToken), CancellationToken.None), component, operation, warningWriter); + return Observe(Task.Run(() => action(cancellationToken), cancellationToken), component, operation, warningWriter); } internal static Task Observe( diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 0b0ad866b7..6da7676ed1 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -27,6 +27,7 @@ internal sealed class LspServer : IDisposable internal const int MaxJsonDepth = 32; internal const int MaxRequestIdStringChars = 256; internal const int MaxDocumentSymbols = 1000; + internal const int MaxDocumentSymbolMaterialization = MaxDocumentSymbols; internal const int MaxDocumentSymbolDetailChars = 512; internal const int MaxDocumentSymbolResponseBytes = 512 * 1024; internal const int MaxPositionLineChars = 16 * 1024; @@ -133,6 +134,12 @@ public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOpti _workspaceFolders.Add(Path.GetFullPath(_projectRoot)); } + /// + /// Compatibility wrapper that runs without caller cancellation. Prefer the overload that + /// accepts when the caller has a shutdown or disconnect token. + /// caller cancellation を持たない互換 wrapper。shutdown / disconnect token がある場合は + /// を使う。 + /// public int Run(Stream input, Stream output) => Run(input, output, CancellationToken.None); public int Run(Stream input, Stream output, CancellationToken cancellationToken) @@ -141,6 +148,7 @@ public int Run(Stream input, Stream output, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var response = HandleMessage(payload); + cancellationToken.ThrowIfCancellationRequested(); if (response != null) WriteMessage(output, response.ToJsonString(_jsonOptions)); if (_exitRequested) @@ -567,13 +575,22 @@ private JsonArray DocumentSymbol(JsonElement root) if (indexedPath == null) return []; - var symbols = _reader.SearchSymbols((string?)null, MaxDocumentSymbols, pathPatterns: [indexedPath]) + var candidates = _reader.SearchSymbols((string?)null, MaxDocumentSymbolMaterialization + 1, pathPatterns: [indexedPath]); + var materializationTruncated = candidates.Count > MaxDocumentSymbolMaterialization; + var materializedCount = Math.Min(candidates.Count, MaxDocumentSymbolMaterialization); + Activity.Current?.SetTag("lsp.document_symbols.materialized_count", materializedCount); + Activity.Current?.SetTag("lsp.document_symbols.materialization_truncated", materializationTruncated); + + var symbols = candidates + .Take(MaxDocumentSymbolMaterialization) .OrderBy(s => s.StartLine) .ThenByDescending(s => s.EndLine) .ThenBy(s => s.ContainerName == null ? 0 : 1) .ThenBy(s => s.Name, StringComparer.Ordinal) .ToList(); - return BuildDocumentSymbolTree(symbols); + var roots = BuildDocumentSymbolTree(symbols); + Activity.Current?.SetTag("lsp.document_symbols.returned_root_count", roots.Count); + return roots; } private JsonArray BuildDocumentSymbolTree(IReadOnlyList symbols) @@ -997,15 +1014,24 @@ private static void AddDocumentSymbolChild(JsonObject parent, JsonObject child) private void TrimDocumentSymbolsToBudget(JsonArray roots) { - while (roots.Count > 0 - && Encoding.UTF8.GetByteCount(roots.ToJsonString(_jsonOptions)) > MaxDocumentSymbolResponseBytes - && RemoveLastDocumentSymbol(roots)) + var responseBytes = MeasureJsonUtf8Bytes(roots); + while (roots.Count > 0 && responseBytes > MaxDocumentSymbolResponseBytes) { + if (!RemoveLastDocumentSymbol(roots, out var removedBytes)) + break; + + if (_jsonOptions.WriteIndented) + responseBytes = MeasureJsonUtf8Bytes(roots); + else + responseBytes = removedBytes > 0 + ? Math.Max(0, responseBytes - removedBytes) + : MeasureJsonUtf8Bytes(roots); } } - private static bool RemoveLastDocumentSymbol(JsonArray symbols) + private bool RemoveLastDocumentSymbol(JsonArray symbols, out int removedBytes) { + removedBytes = 0; if (symbols.Count == 0) return false; @@ -1013,18 +1039,63 @@ private static bool RemoveLastDocumentSymbol(JsonArray symbols) && last["children"] is JsonArray children && children.Count > 0) { - if (RemoveLastDocumentSymbol(children)) + var beforeBytes = MeasureJsonUtf8Bytes(last); + if (RemoveLastDocumentSymbol(children, out _)) { if (children.Count == 0) last.Remove("children"); + removedBytes = Math.Max(0, beforeBytes - MeasureJsonUtf8Bytes(last)); return true; } } + removedBytes = MeasureJsonUtf8Bytes(symbols[symbols.Count - 1]) + + (symbols.Count > 1 ? 1 : 0); symbols.RemoveAt(symbols.Count - 1); return true; } + private int MeasureJsonUtf8Bytes(JsonNode? node) + { + if (node == null) + return "null"u8.Length; + if (_jsonOptions.WriteIndented) + return Encoding.UTF8.GetByteCount(node.ToJsonString(_jsonOptions)); + + if (node is JsonArray array) + { + var bytes = "[]"u8.Length; + for (var i = 0; i < array.Count; i++) + { + if (i > 0) + bytes++; + bytes += MeasureJsonUtf8Bytes(array[i]); + } + return bytes; + } + + if (node is JsonObject obj) + { + var bytes = "{}"u8.Length; + var propertyIndex = 0; + foreach (var property in obj) + { + if (propertyIndex > 0) + bytes++; + bytes += MeasureJsonStringUtf8Bytes(property.Key); + bytes++; + bytes += MeasureJsonUtf8Bytes(property.Value); + propertyIndex++; + } + return bytes; + } + + return Encoding.UTF8.GetByteCount(node.ToJsonString(_jsonOptions)); + } + + private int MeasureJsonStringUtf8Bytes(string value) => + Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(value, _jsonOptions)); + private List ResolveLspDefinitions(PositionTokenContext context) { var localDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true, pathPatterns: [context.IndexedPath]); @@ -1704,6 +1775,12 @@ private static bool TryGetWorkspaceFolderPath(JsonElement folder, out string pat }, }; + /// + /// Compatibility wrapper that reads without caller cancellation. Prefer the overload that + /// accepts for cancellable transports. + /// caller cancellation を持たない互換 wrapper。キャンセル可能な transport では + /// token を受け取る overload を使う。 + /// internal static bool TryReadMessage(Stream input, out string payload) => TryReadMessage(input, out payload, CancellationToken.None); diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index b3d0c469ee..0654037930 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -398,6 +398,11 @@ private async Task AcceptLoopAsync(CancellationToken cancellationToken) continue; } + // The handler owns a semaphore slot. Do not use the shutdown token as the + // scheduling token here, because a pre-canceled Task.Run would skip the + // handler's finally block and leak the slot. + // handler は semaphore slot を所有する。pre-canceled Task.Run で finally が + // 走らず slot が漏れないよう、shutdown token は handler 内だけに渡す。 _ = BackgroundTaskObserver.Run( () => RunHandlerAsync(context, cancellationToken), "cdidx-mcp-http", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 7c243e410f..9a30efcb74 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -47,6 +47,7 @@ public partial class McpServer }; internal const int MaxMcpIndexFailureMessageLength = 512; internal static Action? McpIndexFileCommittedForTesting { get; set; } + internal static Func? StatusUpdateCheckForTesting { get; set; } private QueryCommandRunner.ProjectFilterRootResolution? _projectFilterRootResolutionForCurrentToolCall; // --- Tool implementations / ツール実装 --- @@ -3176,8 +3177,10 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) .ToList(); } status.Version = _version; + var requestToken = _currentRequestToken.Value; + requestToken.ThrowIfCancellationRequested(); status.UpdateCheck = runUpdateCheck - ? UpdateChecker.Check(_version, CancellationToken.None) + ? (StatusUpdateCheckForTesting ?? UpdateChecker.Check)(_version, requestToken) : null; if (!status.FoldReady) { diff --git a/tests/CodeIndex.Tests/BackgroundTaskObserverTests.cs b/tests/CodeIndex.Tests/BackgroundTaskObserverTests.cs index bdb02adf55..968ee6c4e0 100644 --- a/tests/CodeIndex.Tests/BackgroundTaskObserverTests.cs +++ b/tests/CodeIndex.Tests/BackgroundTaskObserverTests.cs @@ -45,4 +45,30 @@ public async Task Run_DoesNotReportCanceledBackgroundTasks_Issue3401() Assert.Empty(messages); } + + [Fact] + public async Task Run_UsesShutdownCancellationWhenScheduling_Issue3760() + { + var started = false; + var messages = new List(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var task = BackgroundTaskObserver.Run( + _ => + { + started = true; + return Task.CompletedTask; + }, + "cdidx-test", + "shutdown worker", + cts.Token, + messages.Add); + + await Assert.ThrowsAnyAsync(async () => await task); + await Task.Delay(50); + + Assert.False(started); + Assert.Empty(messages); + } } diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerHeartbeatTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerHeartbeatTests.cs new file mode 100644 index 0000000000..485629fa0a --- /dev/null +++ b/tests/CodeIndex.Tests/IndexCommandRunnerHeartbeatTests.cs @@ -0,0 +1,64 @@ +using CodeIndex.Cli; + +namespace CodeIndex.Tests; + +public class IndexCommandRunnerHeartbeatTests +{ + [Fact] + public async Task StartObservedJsonPhaseHeartbeat_ReportsFailures_Issue3705() + { + var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var heartbeat = IndexCommandRunner.StartObservedJsonPhaseHeartbeat( + enabled: true, + component: "cdidx-test", + phase: "testing", + messageWriter: _ => { }, + detailProvider: () => throw new InvalidOperationException("failed token=secret /tmp/private/file.txt"), + interval: TimeSpan.Zero, + warningWriter: message => reported.TrySetResult(message)); + + Assert.NotNull(heartbeat); + try + { + await Assert.ThrowsAsync(async () => await heartbeat.Value.Task); + var warning = await reported.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Contains("Warning: background task 'testing heartbeat' failed in cdidx-test", warning); + Assert.Contains("invalid_operation: InvalidOperationException", warning); + Assert.DoesNotContain("secret", warning); + Assert.DoesNotContain("/tmp/private", warning); + } + finally + { + heartbeat.Value.Cts.Cancel(); + heartbeat.Value.Cts.Dispose(); + } + } + + [Fact] + public async Task StartObservedJsonPhaseHeartbeat_StopsQuietlyOnExpectedCancellation_Issue3705() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var warnings = new List(); + var heartbeat = IndexCommandRunner.StartObservedJsonPhaseHeartbeat( + enabled: true, + component: "cdidx-test", + phase: "testing", + messageWriter: _ => { }, + detailProvider: () => + { + started.TrySetResult(); + return null; + }, + interval: TimeSpan.FromMilliseconds(1), + warningWriter: warnings.Add); + + Assert.NotNull(heartbeat); + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + heartbeat.Value.Cts.Cancel(); + await heartbeat.Value.Task.WaitAsync(TimeSpan.FromSeconds(5)); + heartbeat.Value.Cts.Dispose(); + + Assert.Empty(warnings); + } +} diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 2d5cbd7ada..1f8f283394 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1010,8 +1010,10 @@ public void HandleMessage_DocumentSymbol_RejectsOversizedTextDocumentUri_Issue31 } } - [Fact] - public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue3130() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue3130_Issue3743(bool writeIndented) { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_budget"); try @@ -1027,7 +1029,11 @@ public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue31 File.WriteAllText(sourcePath, source.ToString()); TestProjectHelper.InsertIndexedFile(dbPath, "large.cs", "csharp", source.ToString()); using var db = new DbContext(dbPath); - using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var jsonOptions = new JsonSerializerOptions(ProgramRunner.CreateDefaultJsonOptions()) + { + WriteIndented = writeIndented, + }; + using var server = new LspServer(new DbReader(db), "1.2.3", jsonOptions, projectRoot); var request = JsonSerializer.Serialize(new { jsonrpc = "2.0", @@ -1045,8 +1051,9 @@ public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue31 var symbols = response!["result"]!.AsArray(); Assert.NotEmpty(symbols); Assert.True(symbols.Count < LspServer.MaxDocumentSymbols); - Assert.True(Encoding.UTF8.GetByteCount(symbols.ToJsonString()) <= LspServer.MaxDocumentSymbolResponseBytes); + Assert.True(Encoding.UTF8.GetByteCount(symbols.ToJsonString(jsonOptions)) <= LspServer.MaxDocumentSymbolResponseBytes); var allSymbols = FlattenDocumentSymbols(symbols).ToArray(); + Assert.True(allSymbols.Length < LspServer.MaxDocumentSymbols); Assert.Contains(allSymbols, symbol => { var detail = symbol?["detail"]?.GetValue(); @@ -1066,6 +1073,56 @@ public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue31 } } + [Fact] + public void HandleMessage_DocumentSymbol_CapsMaterializationBeforeSorting_Issue3758() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_materialization"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "materialization.cs"); + var source = new StringBuilder("class MaterializationBudget\n{\n"); + for (var i = 0; i < LspServer.MaxDocumentSymbolMaterialization + 25; i++) + source.Append(" void Method").Append(i.ToString("D4", CultureInfo.InvariantCulture)).Append("() { }\n"); + source.Append("}\n"); + + File.WriteAllText(sourcePath, source.ToString()); + TestProjectHelper.InsertIndexedFile(dbPath, "materialization.cs", "csharp", source.ToString()); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3758, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + }, + }); + + using var activity = new Activity("lsp-document-symbol-test").Start(); + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var roots = response!["result"]!.AsArray(); + var root = Assert.Single(roots); + Assert.Equal("MaterializationBudget", root!["name"]!.GetValue()); + var children = root["children"]!.AsArray(); + Assert.True(children.Count < LspServer.MaxDocumentSymbolMaterialization); + Assert.Equal("Method0000", children[0]!["name"]!.GetValue()); + Assert.Equal("Method0001", children[1]!["name"]!.GetValue()); + Assert.Equal("Method0002", children[2]!["name"]!.GetValue()); + Assert.Equal(LspServer.MaxDocumentSymbolMaterialization, GetActivityTag(activity, "lsp.document_symbols.materialized_count")); + Assert.Equal(true, GetActivityTag(activity, "lsp.document_symbols.materialization_truncated")); + Assert.Equal(roots.Count, GetActivityTag(activity, "lsp.document_symbols.returned_root_count")); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_DocumentSymbol_RejectsNonStringTextDocumentUri_Issue3203() { @@ -2088,6 +2145,9 @@ private static int CharacterOf(string source, int line, string value) } } + private static object? GetActivityTag(Activity activity, string key) => + activity.TagObjects.FirstOrDefault(tag => tag.Key == key).Value; + private static string BuildNestedLspRequest(int nestedObjectCount) { var builder = new StringBuilder("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":"""); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 71cfa6812c..063e1629b0 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -7757,6 +7757,37 @@ public void ToolsCall_Status_ReportsResponseByteLimitCaps() Assert.Equal(McpServer.MaxBatchRequestCount, limits["json_rpc_batch_max_requests"]!.GetValue()); } + [Fact] + public async Task ToolsCall_StatusUpdateCheck_UsesRequestCancellationToken_Issue3658() + { + var previous = McpServer.StatusUpdateCheckForTesting; + using var cts = new CancellationTokenSource(); + var observedToken = CancellationToken.None; + var observedCallerCancellation = false; + McpServer.StatusUpdateCheckForTesting = (version, token) => + { + observedToken = token; + cts.Cancel(); + observedCallerCancellation = token.IsCancellationRequested; + return UpdateChecker.CreateDisabledResult(version); + }; + try + { + using var server = new McpServer(_dbPath, "1.0", dbPathExplicit: true); + var transport = new QueuedFrameTransport( + """{"jsonrpc":"2.0","id":3658,"method":"tools/call","params":{"name":"status","arguments":{"updateCheck":true}}}"""); + + await server.RunAsync(transport, cts.Token).WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.True(observedToken.CanBeCanceled); + Assert.True(observedCallerCancellation); + } + finally + { + McpServer.StatusUpdateCheckForTesting = previous; + } + } + [Fact] public void ToolsList_BatchQuerySchemaAdvertisesLimitsAndControls_Issue3539() { @@ -15034,7 +15065,7 @@ public async Task ProcessFrameAsync_RequestTimeout_ReturnsStructuredTimeoutError { using var server = new McpServer(_dbPath, "1.0", dbPathExplicit: false) { - RequestTimeout = TimeSpan.FromMilliseconds(250), + RequestTimeout = TimeSpan.FromMilliseconds(500), }; using var delayStarted = new ManualResetEventSlim(false); using var releaseDelay = new ManualResetEventSlim(false); @@ -15065,6 +15096,7 @@ public async Task ProcessFrameAsync_RequestTimeout_ReturnsStructuredTimeoutError var status = server.HandleMessage(JsonNode.Parse( """{"jsonrpc":"2.0","id":124,"method":"tools/call","params":{"name":"status"}}""")!)!; + Assert.True(status["result"] is not null, status.ToJsonString()); var requestTimeouts = status["result"]!["structuredContent"]!["mcp"]!["request_timeouts"]!; Assert.Equal(1L, requestTimeouts["isolated_action_draining_count"]!.GetValue()); Assert.Equal(0L, requestTimeouts["isolated_action_drained_count"]!.GetValue()); diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index 5531094fbc..f530b40b8d 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -267,6 +267,22 @@ await Assert.ThrowsAsync(() => Assert.Empty(_store.LoadAll()); } + [Fact] + public void TryAddAndSubmit_CanceledBeforeReservation_PropagatesWithoutPersisting_Issue3658() + { + var record = MakeRecord("other", null, "Sync canceled before reservation"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.Throws(() => + _store.TryAddAndSubmit( + record, + _ => SuggestionStore.SubmitAttemptResult.Success("https://github.com/widthdom/CodeIndex/issues/123"), + cts.Token)); + + Assert.Empty(_store.LoadAll()); + } + [Fact] public async Task TryAddAndSubmitAsync_PassesCancellationTokenToSubmitCallback() {