From 18d9c2904b775cfcf3c56e45a54c97c644cc2fa3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 20:44:25 +0900 Subject: [PATCH 1/9] Honor observer shutdown cancellation (#3760) --- changelog.d/unreleased/3760.fixed.md | 16 ++++++++++++ .../Diagnostics/BackgroundTaskObserver.cs | 4 +-- .../BackgroundTaskObserverTests.cs | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3760.fixed.md 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/Diagnostics/BackgroundTaskObserver.cs b/src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs index 15cc564ff2..44cc8f0ef9 100644 --- a/src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs +++ b/src/CodeIndex/Diagnostics/BackgroundTaskObserver.cs @@ -13,7 +13,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( @@ -25,7 +25,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/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); + } } From 65b9eea5c723ba9de897c01a8ae397588f9d7c16 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 20:49:21 +0900 Subject: [PATCH 2/9] Observe index heartbeat tasks (#3705) --- changelog.d/unreleased/3705.fixed.md | 18 +++++ .../Cli/IndexCommandRunner.FullScan.cs | 33 ++------- .../Cli/IndexCommandRunner.UpdateTargets.cs | 72 +++++++++++++------ src/CodeIndex/Mcp/HttpMcpTransport.cs | 5 +- .../IndexCommandRunnerHeartbeatTests.cs | 64 +++++++++++++++++ 5 files changed, 143 insertions(+), 49 deletions(-) create mode 100644 changelog.d/unreleased/3705.fixed.md create mode 100644 tests/CodeIndex.Tests/IndexCommandRunnerHeartbeatTests.cs diff --git a/changelog.d/unreleased/3705.fixed.md b/changelog.d/unreleased/3705.fixed.md new file mode 100644 index 0000000000..057267888c --- /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 through the shared background task observer while keeping expected cancellation quiet. + +## 日本語 + +- JSON index heartbeat タスクを共通の background task observer 経由にし、期待されるキャンセルでは診断を出さないようにしました。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index a9b8bd8844..4aee5abcfc 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -664,33 +664,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 55852adacf..140a596e16 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, + Console.Error.WriteLine, + 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}"; - Console.Error.WriteLine($"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/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index b3d0c469ee..6e737996c7 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -399,9 +399,10 @@ private async Task AcceptLoopAsync(CancellationToken cancellationToken) } _ = BackgroundTaskObserver.Run( - () => RunHandlerAsync(context, cancellationToken), + token => RunHandlerAsync(context, token), "cdidx-mcp-http", - "request handler"); + "request handler", + cancellationToken); } } finally 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); + } +} From 281310e393c32ee26901f0bf82ca473ebf4deddd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 20:59:02 +0900 Subject: [PATCH 3/9] Route cancellation through status update checks (#3658) --- changelog.d/unreleased/3658.fixed.md | 19 ++++++++++++ src/CodeIndex/Cli/SuggestionStore.cs | 13 +++++++- src/CodeIndex/Lsp/LspServer.cs | 13 ++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 5 ++- tests/CodeIndex.Tests/McpServerTests.cs | 31 +++++++++++++++++++ tests/CodeIndex.Tests/SuggestionStoreTests.cs | 16 ++++++++++ 6 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3658.fixed.md 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/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index 7fd9899593..6a4583b04d 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/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 0b0ad866b7..03d6d539c6 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -133,6 +133,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 +147,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) @@ -1704,6 +1711,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/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 23492543e3..824ff94048 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 / ツール実装 --- @@ -3170,8 +3171,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/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index e502526a15..dc49c59be3 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -7726,6 +7726,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() { 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() { From 2fa7c8bab596cccd20111dfe34072bdeb6862f3f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 21:04:55 +0900 Subject: [PATCH 4/9] Avoid repeated document symbol serialization (#3743) --- changelog.d/unreleased/3743.fixed.md | 16 +++++++ src/CodeIndex/Lsp/LspServer.cs | 59 ++++++++++++++++++++++--- tests/CodeIndex.Tests/LspServerTests.cs | 3 +- 3 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 changelog.d/unreleased/3743.fixed.md 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/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 03d6d539c6..377dface83 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -1004,15 +1004,21 @@ 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; + + 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; @@ -1020,18 +1026,61 @@ 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 (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]); diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 2d5cbd7ada..33b52c0edb 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1011,7 +1011,7 @@ public void HandleMessage_DocumentSymbol_RejectsOversizedTextDocumentUri_Issue31 } [Fact] - public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue3130() + public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue3130_Issue3743() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_budget"); try @@ -1047,6 +1047,7 @@ public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue31 Assert.True(symbols.Count < LspServer.MaxDocumentSymbols); Assert.True(Encoding.UTF8.GetByteCount(symbols.ToJsonString()) <= LspServer.MaxDocumentSymbolResponseBytes); var allSymbols = FlattenDocumentSymbols(symbols).ToArray(); + Assert.True(allSymbols.Length < LspServer.MaxDocumentSymbols); Assert.Contains(allSymbols, symbol => { var detail = symbol?["detail"]?.GetValue(); From a5726813f3d77cf48212acd54dfbde474e2504d4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 21:08:06 +0900 Subject: [PATCH 5/9] Cap document symbol materialization (#3758) --- changelog.d/unreleased/3758.fixed.md | 16 ++++++++ src/CodeIndex/Lsp/LspServer.cs | 14 ++++++- tests/CodeIndex.Tests/LspServerTests.cs | 53 +++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3758.fixed.md 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/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 377dface83..d200f8d744 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; @@ -574,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) diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 33b52c0edb..00bf633b40 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1067,6 +1067,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() { @@ -2089,6 +2139,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":"""); From ee5eba15390288dad095e793693396d233ba8e7d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 21:45:07 +0900 Subject: [PATCH 6/9] Stabilize timeout diagnostics test (#3658) --- tests/CodeIndex.Tests/McpServerTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index dc49c59be3..22540b46fa 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -14971,7 +14971,7 @@ public async Task ProcessFrameAsync_RequestTimeout_ReturnsStructuredTimeoutError { using var server = new McpServer(_dbPath, "1.0", dbPathExplicit: false) { - RequestTimeout = TimeSpan.FromMilliseconds(20), + RequestTimeout = TimeSpan.FromMilliseconds(500), }; using var delayStarted = new ManualResetEventSlim(false); using var releaseDelay = new ManualResetEventSlim(false); @@ -15002,6 +15002,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()); From a0caf9c2865036d92f9a569244ca664283bd73d9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 22:47:08 +0900 Subject: [PATCH 7/9] Honor pretty JSON document symbol budgets (#3743) --- src/CodeIndex/Lsp/LspServer.cs | 11 ++++++++--- tests/CodeIndex.Tests/LspServerTests.cs | 14 ++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index d200f8d744..6da7676ed1 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -1020,9 +1020,12 @@ private void TrimDocumentSymbolsToBudget(JsonArray roots) if (!RemoveLastDocumentSymbol(roots, out var removedBytes)) break; - responseBytes = removedBytes > 0 - ? Math.Max(0, responseBytes - removedBytes) - : MeasureJsonUtf8Bytes(roots); + if (_jsonOptions.WriteIndented) + responseBytes = MeasureJsonUtf8Bytes(roots); + else + responseBytes = removedBytes > 0 + ? Math.Max(0, responseBytes - removedBytes) + : MeasureJsonUtf8Bytes(roots); } } @@ -1056,6 +1059,8 @@ 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) { diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 00bf633b40..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_Issue3743() + [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,7 +1051,7 @@ 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 => From 773025ee50111b0ca9983a687abbedbedbd654ea Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 23:52:49 +0900 Subject: [PATCH 8/9] Harden observed HTTP handler scheduling (#3705) --- src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs | 2 +- src/CodeIndex/Mcp/HttpMcpTransport.cs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs b/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs index 140a596e16..2ea49e55de 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs @@ -127,7 +127,7 @@ private static (CancellationTokenSource Cts, Task Task)? StartIndexJsonPhaseHear options.Json && !options.Quiet, "cdidx-index", phase, - Console.Error.WriteLine, + ConsoleUi.TryWriteErrorLine, detailProvider); } diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index 6e737996c7..0654037930 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -398,11 +398,15 @@ 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( - token => RunHandlerAsync(context, token), + () => RunHandlerAsync(context, cancellationToken), "cdidx-mcp-http", - "request handler", - cancellationToken); + "request handler"); } } finally From a43816ea76f9f9501473f127378d7ccc30ca58c9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 00:09:29 +0900 Subject: [PATCH 9/9] Document HTTP handler scheduling fix (#3705) --- changelog.d/unreleased/3705.fixed.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/unreleased/3705.fixed.md b/changelog.d/unreleased/3705.fixed.md index 057267888c..443a61205b 100644 --- a/changelog.d/unreleased/3705.fixed.md +++ b/changelog.d/unreleased/3705.fixed.md @@ -11,8 +11,8 @@ affected: ## English -- Route JSON index heartbeat tasks through the shared background task observer while keeping expected cancellation quiet. +- 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 タスクを共通の background task observer 経由にし、期待されるキャンセルでは診断を出さないようにしました。 +- JSON index heartbeat タスクと HTTP MCP request-handler タスクを監視対象の background work に通し、期待されるキャンセルでは診断を出さず、shutdown 中も handler slot cleanup を保つようにしました。