From 86af207089ebabc6a0fd2681fb8ffa295a517414 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 00:27:58 +0900 Subject: [PATCH 1/3] Fix LSP malformed JSON handling (#2829) --- changelog.d/unreleased/2829.fixed.md | 16 +++++++ src/CodeIndex/Lsp/LspServer.cs | 64 ++++++++++++++++--------- tests/CodeIndex.Tests/LspServerTests.cs | 35 ++++++++++++++ 3 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 changelog.d/unreleased/2829.fixed.md diff --git a/changelog.d/unreleased/2829.fixed.md b/changelog.d/unreleased/2829.fixed.md new file mode 100644 index 0000000000..53b927d489 --- /dev/null +++ b/changelog.d/unreleased/2829.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2829 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs +--- + +## English + +- **LSP malformed JSON frames no longer terminate the server loop (#2829)** — malformed JSON-RPC payloads now return a parse error response, allowing the stdio LSP loop to continue processing later valid frames. + +## 日本語 + +- **LSP の malformed JSON frame で server loop が終了しなくなりました (#2829)** — 不正な JSON-RPC payload は parse error response として扱われ、stdio LSP loop が後続の有効な frame を処理し続けられるようになりました。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 5a360ceb57..0599ac0154 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -43,33 +43,51 @@ public void Run(Stream input, Stream output) internal JsonObject? HandleMessage(string payload) { - using var document = JsonDocument.Parse(payload); - var root = document.RootElement; - var method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() : null; - var hasId = root.TryGetProperty("id", out var idElement); - JsonNode? id = hasId ? JsonNode.Parse(idElement.GetRawText()) : null; - - if (method == null) - return hasId ? Error(id, -32600, "Invalid Request") : null; - + JsonDocument document; try { - return method switch - { - "initialize" => Result(id, BuildInitializeResult()), - "initialized" => null, - "shutdown" => HandleShutdown(id), - "exit" => null, - "workspace/symbol" => Result(id, WorkspaceSymbol(root)), - "textDocument/documentSymbol" => Result(id, DocumentSymbol(root)), - "textDocument/definition" => Result(id, Definition(root)), - "textDocument/references" => Result(id, References(root)), - _ => hasId ? Error(id, -32601, $"Method not found: {method}") : null, - }; + document = JsonDocument.Parse(payload); + } + catch (JsonException) + { + return Error(null, -32700, "Parse error"); } - catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or JsonException or IOException) + + using (document) { - return hasId ? Error(id, -32602, ex.Message) : null; + JsonNode? id = null; + var hasId = false; + + try + { + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return Error(null, -32600, "Invalid Request"); + + var method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() : null; + hasId = root.TryGetProperty("id", out var idElement); + id = hasId ? JsonNode.Parse(idElement.GetRawText()) : null; + + if (method == null) + return hasId ? Error(id, -32600, "Invalid Request") : null; + + return method switch + { + "initialize" => Result(id, BuildInitializeResult()), + "initialized" => null, + "shutdown" => HandleShutdown(id), + "exit" => null, + "workspace/symbol" => Result(id, WorkspaceSymbol(root)), + "textDocument/documentSymbol" => Result(id, DocumentSymbol(root)), + "textDocument/definition" => Result(id, Definition(root)), + "textDocument/references" => Result(id, References(root)), + _ => hasId ? Error(id, -32601, $"Method not found: {method}") : null, + }; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or JsonException or IOException) + { + return hasId ? Error(id, -32602, ex.Message) : null; + } } } diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 34149cdd0d..eabd81e7d2 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -83,6 +83,38 @@ public void HandleMessage_Initialize_AdvertisesCoreCapabilities() } } + [Fact] + public void Run_MalformedJsonFrame_WritesParseErrorAndContinues() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_malformed_json"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + const string initializeRequest = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"; + using var input = new MemoryStream(Encoding.UTF8.GetBytes(Frame("{") + Frame(initializeRequest))); + using var output = new MemoryStream(); + + server.Run(input, output); + + output.Position = 0; + Assert.True(LspServer.TryReadMessage(output, out var parseErrorPayload)); + using var parseError = JsonDocument.Parse(parseErrorPayload); + Assert.Equal(-32700, parseError.RootElement.GetProperty("error").GetProperty("code").GetInt32()); + Assert.Equal(JsonValueKind.Null, parseError.RootElement.GetProperty("id").ValueKind); + + Assert.True(LspServer.TryReadMessage(output, out var initializePayload)); + using var initialize = JsonDocument.Parse(initializePayload); + Assert.True(initialize.RootElement.GetProperty("result").GetProperty("capabilities").GetProperty("definitionProvider").GetBoolean()); + Assert.False(LspServer.TryReadMessage(output, out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_DocumentSymbol_ReturnsIndexedSymbols() { @@ -419,4 +451,7 @@ private static string CreateDefinitionRequest(string sourcePath, int id, int lin position = new { line, character }, }, }); + + private static string Frame(string payload) => + $"Content-Length: {Encoding.UTF8.GetByteCount(payload)}\r\n\r\n{payload}"; } From 80d467ea91aa08a84911ff5d05fb37f6e0201cc7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 00:37:39 +0900 Subject: [PATCH 2/3] Stop LSP loop on exit notification (#2830) --- changelog.d/unreleased/2830.fixed.md | 16 +++++++++++++ src/CodeIndex/Lsp/LspServer.cs | 11 ++++++++- tests/CodeIndex.Tests/LspServerTests.cs | 31 +++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/2830.fixed.md diff --git a/changelog.d/unreleased/2830.fixed.md b/changelog.d/unreleased/2830.fixed.md new file mode 100644 index 0000000000..35d5a0bb55 --- /dev/null +++ b/changelog.d/unreleased/2830.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2830 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs +--- + +## English + +- **LSP shutdown and exit now terminate the server loop cleanly (#2830)** — the stdio LSP loop now observes `exit` notifications after `shutdown`, returns after the lifecycle terminates, and avoids processing later frames. + +## 日本語 + +- **LSP の shutdown と exit で server loop が正常終了するようになりました (#2830)** — stdio LSP loop は `shutdown` 後の `exit` 通知を監視して lifecycle 終了後に戻り、後続の frame を処理しないようになりました。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 0599ac0154..c03f73c745 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -21,6 +21,7 @@ internal sealed class LspServer : IDisposable private readonly string? _projectRoot; private readonly StringComparison _pathStringComparison; private bool _shutdownRequested; + private bool _exitRequested; public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOptions, string? projectRoot = null) { @@ -38,6 +39,8 @@ public void Run(Stream input, Stream output) var response = HandleMessage(payload); if (response != null) WriteMessage(output, response.ToJsonString(_jsonOptions)); + if (_exitRequested) + break; } } @@ -76,7 +79,7 @@ public void Run(Stream input, Stream output) "initialize" => Result(id, BuildInitializeResult()), "initialized" => null, "shutdown" => HandleShutdown(id), - "exit" => null, + "exit" => HandleExit(), "workspace/symbol" => Result(id, WorkspaceSymbol(root)), "textDocument/documentSymbol" => Result(id, DocumentSymbol(root)), "textDocument/definition" => Result(id, Definition(root)), @@ -97,6 +100,12 @@ private JsonObject HandleShutdown(JsonNode? id) return Result(id, null); } + private JsonObject? HandleExit() + { + _exitRequested = true; + return null; + } + private JsonObject BuildInitializeResult() => new() { ["capabilities"] = new JsonObject diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index eabd81e7d2..664fba941f 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -115,6 +115,37 @@ public void Run_MalformedJsonFrame_WritesParseErrorAndContinues() } } + [Fact] + public void Run_ShutdownThenExit_StopsBeforeLaterFrames() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_shutdown_exit"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + const string shutdownRequest = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"shutdown\"}"; + const string exitNotification = "{\"jsonrpc\":\"2.0\",\"method\":\"exit\"}"; + const string initializeRequest = "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"initialize\",\"params\":{}}"; + using var input = new MemoryStream(Encoding.UTF8.GetBytes( + Frame(shutdownRequest) + Frame(exitNotification) + Frame(initializeRequest))); + using var output = new MemoryStream(); + + server.Run(input, output); + + output.Position = 0; + Assert.True(LspServer.TryReadMessage(output, out var shutdownPayload)); + using var shutdown = JsonDocument.Parse(shutdownPayload); + Assert.Equal(2, shutdown.RootElement.GetProperty("id").GetInt32()); + Assert.Equal(JsonValueKind.Null, shutdown.RootElement.GetProperty("result").ValueKind); + Assert.False(LspServer.TryReadMessage(output, out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_DocumentSymbol_ReturnsIndexedSymbols() { From 64fd0f88ba364205deec8b4d58305d295759b5f1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:01:53 +0900 Subject: [PATCH 3/3] Honor LSP exit status before shutdown (#2830) --- changelog.d/unreleased/2830.fixed.md | 5 ++-- src/CodeIndex/Cli/ProgramRunner.cs | 3 +-- src/CodeIndex/Lsp/LspServer.cs | 6 ++++- tests/CodeIndex.Tests/LspServerTests.cs | 32 +++++++++++++++++++++++-- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/changelog.d/unreleased/2830.fixed.md b/changelog.d/unreleased/2830.fixed.md index 35d5a0bb55..91ad9482b3 100644 --- a/changelog.d/unreleased/2830.fixed.md +++ b/changelog.d/unreleased/2830.fixed.md @@ -3,14 +3,15 @@ category: fixed issues: - 2830 affected: + - src/CodeIndex/Cli/ProgramRunner.cs - src/CodeIndex/Lsp/LspServer.cs - tests/CodeIndex.Tests/LspServerTests.cs --- ## English -- **LSP shutdown and exit now terminate the server loop cleanly (#2830)** — the stdio LSP loop now observes `exit` notifications after `shutdown`, returns after the lifecycle terminates, and avoids processing later frames. +- **LSP shutdown and exit now terminate the server loop cleanly (#2830)** — the stdio LSP loop now observes `exit` notifications, returns after the lifecycle terminates, avoids processing later frames, and exits non-zero when `exit` arrives before `shutdown`. ## 日本語 -- **LSP の shutdown と exit で server loop が正常終了するようになりました (#2830)** — stdio LSP loop は `shutdown` 後の `exit` 通知を監視して lifecycle 終了後に戻り、後続の frame を処理しないようになりました。 +- **LSP の shutdown と exit で server loop が正常終了するようになりました (#2830)** — stdio LSP loop は `exit` 通知を監視して lifecycle 終了後に戻り、後続の frame を処理せず、`shutdown` 前に `exit` を受け取った場合は非 0 で終了します。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 8e0b80b2c7..4e05025025 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -1594,8 +1594,7 @@ private static int RunLsp(string[] cmdArgs, string appVersion, JsonSerializerOpt } using var server = new LspServer(new DbReader(db), appVersion, jsonOptions, indexedProjectRoot); - server.Run(Console.OpenStandardInput(), Console.OpenStandardOutput()); - return CommandExitCodes.Success; + return server.Run(Console.OpenStandardInput(), Console.OpenStandardOutput()); } catch (OperationCanceledException) { diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index c03f73c745..a1e8d9fba8 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -22,6 +22,7 @@ internal sealed class LspServer : IDisposable private readonly StringComparison _pathStringComparison; private bool _shutdownRequested; private bool _exitRequested; + private bool _exitRequestedBeforeShutdown; public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOptions, string? projectRoot = null) { @@ -32,7 +33,7 @@ public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOpti _pathStringComparison = PathCasing.ComparisonFor(_projectRoot ?? Environment.CurrentDirectory); } - public void Run(Stream input, Stream output) + public int Run(Stream input, Stream output) { while (TryReadMessage(input, out var payload)) { @@ -42,6 +43,8 @@ public void Run(Stream input, Stream output) if (_exitRequested) break; } + + return _exitRequestedBeforeShutdown ? CommandExitCodes.UsageError : CommandExitCodes.Success; } internal JsonObject? HandleMessage(string payload) @@ -102,6 +105,7 @@ private JsonObject HandleShutdown(JsonNode? id) private JsonObject? HandleExit() { + _exitRequestedBeforeShutdown = !_shutdownRequested; _exitRequested = true; return null; } diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 664fba941f..1a69f7f1f1 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -96,8 +96,9 @@ public void Run_MalformedJsonFrame_WritesParseErrorAndContinues() using var input = new MemoryStream(Encoding.UTF8.GetBytes(Frame("{") + Frame(initializeRequest))); using var output = new MemoryStream(); - server.Run(input, output); + var exitCode = server.Run(input, output); + Assert.Equal(CommandExitCodes.Success, exitCode); output.Position = 0; Assert.True(LspServer.TryReadMessage(output, out var parseErrorPayload)); using var parseError = JsonDocument.Parse(parseErrorPayload); @@ -131,8 +132,9 @@ public void Run_ShutdownThenExit_StopsBeforeLaterFrames() Frame(shutdownRequest) + Frame(exitNotification) + Frame(initializeRequest))); using var output = new MemoryStream(); - server.Run(input, output); + var exitCode = server.Run(input, output); + Assert.Equal(CommandExitCodes.Success, exitCode); output.Position = 0; Assert.True(LspServer.TryReadMessage(output, out var shutdownPayload)); using var shutdown = JsonDocument.Parse(shutdownPayload); @@ -146,6 +148,32 @@ public void Run_ShutdownThenExit_StopsBeforeLaterFrames() } } + [Fact] + public void Run_ExitBeforeShutdown_ReturnsUsageError() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_exit_without_shutdown"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + const string exitNotification = "{\"jsonrpc\":\"2.0\",\"method\":\"exit\"}"; + const string initializeRequest = "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"initialize\",\"params\":{}}"; + using var input = new MemoryStream(Encoding.UTF8.GetBytes(Frame(exitNotification) + Frame(initializeRequest))); + using var output = new MemoryStream(); + + var exitCode = server.Run(input, output); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + output.Position = 0; + Assert.False(LspServer.TryReadMessage(output, out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_DocumentSymbol_ReturnsIndexedSymbols() {