diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 13fb0d9b7..3f5c5fcff 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -910,6 +910,14 @@ across live-text eviction and are cleared by `didClose`, so an evicted newer version cannot be replaced by a stale change. Other providers return empty arrays or null when the database cannot answer safely instead of inventing language-server analysis. +Every inbound message must first be an object whose `jsonrpc` member is exactly +the string `"2.0"`. Missing, null, non-string, or other-version envelope values +return `-32600` (`Invalid Request`) with a valid request ID preserved; validation +runs before receive-time lifecycle reservation, cancellation fast paths, method +dispatch, state mutation, or database access. `workspace/symbol` separately +requires `params.query` to be a JSON string. Missing, null, or non-string query +values return `-32602` (`Invalid params`) before query-snapshot refresh or symbol +search, while the valid empty string remains a supported workspace-wide query. Before URI/path resolution, live-document access, or query-snapshot refresh, one shared coordinate validator checks every supported position/range-bearing method. `definition`, `declaration`, `references`, `hover`, `completion`, and @@ -5127,6 +5135,14 @@ indexed symbol に fallback する。numeric document-version tombstone は live 後も上限付きで保持し、`didClose` で消去するため、evict 済みの新しい version を stale change が 置き換えることはない。それ以外の provider は database が安全に答えられない場合、 language-server analysis を作り上げず、空配列または null を返す。 +すべての inbound message は、まず object であり、その `jsonrpc` member が文字列 `"2.0"` と +完全一致しなければならない。欠落、null、文字列以外、または別 version の envelope 値には、 +有効な request ID を維持して `-32600`(`Invalid Request`)を返す。この検証は receive-time の +lifecycle reservation、cancellation fast path、method dispatch、state mutation、database access +より前に行う。`workspace/symbol` では、これとは別に `params.query` を JSON string として必須に +する。query が欠落、null、または文字列以外なら query snapshot の refresh や symbol search より +前に `-32602`(`Invalid params`)を返す一方、正当な空文字列は workspace-wide query として +引き続き対応する。 URI / path 解決、live document へのアクセス、query snapshot の refresh より前に、1つの共通 coordinate validator ですべての対応済み position / range method を検証する。 `definition`、`declaration`、`references`、`hover`、`completion`、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index f105da7ee..b6019aa88 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -692,6 +692,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result - `LspServerTests.cs` LSP JSON-RPC behavior and indexed editor semantics. Reference parity coverage must compare LSP locations with the matching CLI candidate bundle, including overload identity and both `includeDeclaration` states. Keep document/workspace identifier ranges and explicit-versus-inferred inlay hints in one source-semantics fixture so persisted-column anchoring, source confirmation, and hint suppression cannot drift independently. Lifecycle transport coverage must stage initialization before sending shutdown/exit, use a caller-owned stream whose read after the exit frame cannot be interrupted by cancellation, and assert the shutdown response, success versus exit-before-shutdown status, and absence of any post-exit read. Keep finite EOF, malformed-frame, and caller-cancellation coverage alongside it so early transport completion cannot weaken those paths. + Issue-5231 request-boundary coverage uses framed transcripts to prove that an invalid-version initialize cannot mutate lifecycle state before a valid initialize, then sends every missing/null/non-string/wrong-version `jsonrpc` and missing/null/non-string `workspace/symbol.query` case after initialization. Keep valid empty and non-empty query controls in the same seeded fixture, preserve typed response IDs and stable error codes, and assert that rejected frames never reach receive-time reservation, method dispatch, symbol search, or `lsp.request` activity. Separately stage an invalid-version cancellation while a symbol request is active, prove it remains active until a valid cancellation arrives, and pin malformed-envelope/invalid-query error precedence over the overload response. The issue-4870 kind-mapping matrix seeds every registered built-in internal kind plus semantic constructor and enum-member rows, then asserts decoded document-symbol, workspace-symbol, and completion responses. It also feeds real TypeScript, Kotlin, Solidity, Java, Swift, Dart, Visual Basic, Pascal, C#, and Shell extractor output through the shared mapper so keyword, compact, named, dedicated-name, and verbatim-identifier constructors; same-name, return-typed, object/static, and shell non-constructors; decorated, escaped, indirect, and comma-grouped enum entries; and nested enums cannot invert classification. It separately pins the conservative parameter and unknown/plugin fallback. Keep all three protocol surfaces in that one fixture so constructor/import handling and deliberate fallback mappings cannot drift. Coordinate-validation coverage keeps every supported position provider in one negative/malformed/overflow matrix, with missing-document precedence and missing/reversed inlay ranges in the same read-only fixture. Keep UTF-16 and EOF success controls together, but retain invalid ranged-`didChange` as a separate mutable-state fixture so a rejected notification cannot replace accepted live text (#4869). C# semantic-token coverage decodes LSP delta coordinates and pairs the same keyword/modifier, namespace/type, method/property/event/enum-member, parameter/variable/field, generic/record-struct, explicit-interface-member, attribute, using-declaration, accessor-local, numeric member-access, escaped/contextual-identifier, interpolation, comma-separated declaration, and declaration-kind fixture with `QueryCommandRunnerTests.RunExcerpt_JsonCSharpSemanticKindsMatchLspClassifier_Issue4852`. Keep both surfaces on the shared source classifier, retain ordering/non-overlap assertions, preserve the narrow excerpt check whose parameter/type-parameter declarations are outside the returned range, and retain `RunExcerpt_JsonCSharpSemanticTokensUseVisibleRangeBudget_Issue4852` so earlier tokens cannot empty a late excerpt while indexed-source reconstruction remains line/character bounded. `HandleMessage_SemanticTokens_FallsBackToIndexedCSharpSymbolsWhenSourceReadFails_Issue4852` keeps indexed declaration tokens available when bounded source loading fails. @@ -1849,6 +1850,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `LspServerTests.cs` LSP の JSON-RPC 挙動と indexed editor semantics のテスト。reference parity coverage では、overload identity と `includeDeclaration` の両状態を含め、LSP location と対応する CLI candidate bundle を比較してください。document/workspace の identifier range と explicit/inferred inlay hint は 1 つの source-semantics fixture にまとめ、保存済み column の anchoring、source 上の確認、hint 抑制が別々に drift しないようにします。 lifecycle transport coverage では、shutdown/exit の送信前に initialization を stage し、exit frame 後の read が cancellation では中断されない caller-owned stream を使い、shutdown response、success と shutdown 前 exit の status の違い、post-exit read が一度も始まらないことを検証してください。早期 transport completion によって他経路が弱まらないよう、finite EOF、malformed frame、caller cancellation の coverage も並行して維持します。 + issue-5231 の request boundary coverage は framed transcript を使い、不正 version の initialize が lifecycle state を変更せず、その後の正当な initialize が成功することを検証します。続いて initialization 後に、`jsonrpc` の欠落・null・文字列以外・別 version と、`workspace/symbol.query` の欠落・null・文字列以外をすべて送ります。同じ seed 済み fixture に空文字列と非空文字列の正当な query control を保ち、型付き response ID と安定した error code を維持し、拒否 frame が receive-time reservation、method dispatch、symbol search、`lsp.request` activity のいずれにも到達しないことを確認してください。別途、symbol request の実行中に不正 version の cancellation を stage し、正当な cancellation が届くまで request が active のままであること、および malformed envelope / invalid query の error が overload response より優先されることを固定してください。 issue-4870 の kind-mapping matrix は、登録済みの全 built-in internal kind に加えて semantic な constructor と enum-member の row を seed し、decode 済みの document-symbol、workspace-symbol、completion response を検証します。また、実際の TypeScript、Kotlin、Solidity、Java、Swift、Dart、Visual Basic、Pascal、C#、Shell extractor 出力を shared mapper に通し、keyword / compact / named / 専用名 / 逐語識別子 constructor、同名 / 戻り値型付き / object・static / shell の非 constructor、annotation 付き / escape 済み / indirect / comma-grouped enum entry、nested enum の分類が逆転しないようにします。保守的な parameter および未知/plugin fallback は別途固定します。constructor/import の処理と意図的な fallback mapping がずれないよう、3つの protocol surface を同じ fixture に維持してください。 coordinate validation coverage は、対応済みの全 position provider を1つの負数 / malformed / overflow matrix にまとめ、missing document より検証を優先することと、inlay range の欠落 / 逆順も同じ read-only fixture で検証します。UTF-16 と EOF の成功 control は一緒に維持し、不正な range 付き `didChange` は mutable state の独立 fixture として残して、拒否された notification が受理済み live text を置き換えられないことを検証してください(#4869)。 C# semantic-token coverage は LSP の delta 座標を decode し、同じ keyword/modifier、namespace/type、method/property/event/enum-member、parameter/variable/field、generic/record struct、explicit interface member、attribute、using declaration、accessor local、numeric member access、escaped/contextual identifier、interpolation、comma-separated declaration、declaration kind の fixture を `QueryCommandRunnerTests.RunExcerpt_JsonCSharpSemanticKindsMatchLspClassifier_Issue4852` と対にします。両 surface が shared source classifier を使い続け、順序と非 overlap の assertion、parameter / type-parameter の declaration が返却範囲外にある narrow excerpt の検証、および indexed-source reconstruction を line / character 上限内に保ちながら手前の token によって file 後半の excerpt が空にならないことを保証する `RunExcerpt_JsonCSharpSemanticTokensUseVisibleRangeBudget_Issue4852` を維持してください。`HandleMessage_SemanticTokens_FallsBackToIndexedCSharpSymbolsWhenSourceReadFails_Issue4852` は bounded source loading が失敗しても indexed declaration token を利用できる状態を維持します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index c00a6cdba..78d986b6b 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -3297,10 +3297,16 @@ message bodies, so Ctrl-C / host cancellation can interrupt pending frame reads instead of waiting for another complete request. Unknown-method diagnostics echo at most 240 method-name characters with `...` when the method name is longer. Request IDs must be bounded JSON-RPC scalar -values: strings are capped at 256 +values. Every inbound LSP message must be an object whose `jsonrpc` member is +exactly the string `"2.0"`; missing, null, non-string, or other-version values +return `-32600` (`Invalid Request`) before lifecycle reservation, cancellation, +dispatch, state mutation, or database access, while preserving a valid request +ID. Request-ID strings are capped at 256 characters, integer IDs must fit in `Int64`, and non-scalar IDs are rejected as -invalid requests before response IDs are cloned. `workspace/symbol` query -strings are capped at 1000 characters before symbol search runs. +invalid requests before response IDs are cloned. `workspace/symbol.params.query` +is a required JSON string; missing, null, or non-string values return `-32602` +(`Invalid params`) before symbol search, while the empty string remains valid. +Query strings are capped at 1000 characters before symbol search runs. `workspace/symbol` accepts optional numeric `limit` / `maxResults` parameters and clamps them to 1000 results. `textDocument/documentSymbol` returns hierarchical `DocumentSymbol` children when container metadata is available, @@ -6929,10 +6935,16 @@ stdio loop は header / message body 読み取り中も CLI cancellation token Ctrl-C や host cancellation が次の完全な request を待たずに pending frame read を中断できます。 method-not-found diagnostic で echo する method name は最大 240 文字に制限され、 長い場合は `...` を付けて切り詰めます。 +すべての受信 LSP message は object であり、`jsonrpc` member が文字列 `"2.0"` と完全一致する +必要があります。欠落、null、文字列以外、または別 version の値には、有効な request ID を +維持して `-32600`(`Invalid Request`)を返します。この検証は lifecycle reservation、 +cancellation、dispatch、state mutation、database access より前に行います。 request ID は bounded な JSON-RPC scalar value に限定され、string は 256 文字まで、 integer ID は `Int64` に収まるものだけを受理し、non-scalar ID は response ID を複製する前に invalid request として拒否します。 -`workspace/symbol` の query string は symbol search を実行する前に 1000 文字で上限をかけます。 +`workspace/symbol.params.query` は必須の JSON string です。欠落、null、文字列以外の値には +symbol search より前に `-32602`(`Invalid params`)を返す一方、空文字列は引き続き有効です。 +query string は symbol search を実行する前に 1000 文字で上限をかけます。 `workspace/symbol` は任意の numeric `limit` / `maxResults` parameter を受け取り、1000 件までに clamp します。`textDocument/documentSymbol` は container metadata がある場合に階層化された `DocumentSymbol` children を返し、最後に受理した live buffer または index から最大 1000 件の diff --git a/changelog.d/unreleased/5231.fixed.md b/changelog.d/unreleased/5231.fixed.md new file mode 100644 index 000000000..1a2906db2 --- /dev/null +++ b/changelog.d/unreleased/5231.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 5231 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - src/CodeIndex/Lsp/LspServer.Protocol.cs + - src/CodeIndex/Lsp/LspServer.SymbolRequests.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md + - USER_GUIDE.md +--- + +## English + +- **LSP now rejects malformed JSON-RPC envelopes and invalid workspace-symbol query types (#5231)** — extending the protocol-boundary hardening from #4336, #4869, and #3128, the server now requires `jsonrpc: "2.0"` and a string `workspace/symbol.params.query` before lifecycle reservation, dispatch, or database access, while preserving valid empty queries. + +## 日本語 + +- **LSP が不正な JSON-RPC envelope と workspace symbol query の型を拒否するようになりました (#5231)** — #4336、#4869、#3128 の protocol boundary hardening を拡張し、server は lifecycle reservation、dispatch、database access より前に `jsonrpc: "2.0"` と文字列の `workspace/symbol.params.query` を必須にしつつ、正当な空 query は引き続き受け付けます。 diff --git a/src/CodeIndex/Lsp/LspServer.Protocol.cs b/src/CodeIndex/Lsp/LspServer.Protocol.cs index a583d0126..793a7aa57 100644 --- a/src/CodeIndex/Lsp/LspServer.Protocol.cs +++ b/src/CodeIndex/Lsp/LspServer.Protocol.cs @@ -149,13 +149,18 @@ private static string GetTextDocumentUri(JsonElement root) return uri; } - private static string? GetString(JsonElement root, params string[] path) + private static string GetRequiredString(JsonElement root, params string[] path) { if (!TryGet(root, out var value, path) || value.ValueKind != JsonValueKind.String) - return null; - return value.GetString(); + throw new ArgumentException("A required string parameter is missing or invalid."); + return value.GetString()!; } + private static bool HasValidJsonRpcEnvelope(JsonElement root) => + root.TryGetProperty("jsonrpc", out var jsonRpc) + && jsonRpc.ValueKind == JsonValueKind.String + && string.Equals(jsonRpc.GetString(), "2.0", StringComparison.Ordinal); + private static bool? GetBool(JsonElement root, params string[] path) { if (!TryGet(root, out var value, path)) diff --git a/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs b/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs index ee1bf0b67..b32703721 100644 --- a/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs +++ b/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs @@ -93,9 +93,7 @@ private SymbolResponse CreateWorkspaceSymbolResponse( bool createPartialItems, CancellationToken cancellationToken) { - var query = GetString(root, "params", "query"); - if (query != null && query.Length > QueryLimits.MaxQueryLength) - throw new ArgumentException(QueryLimits.FormatQueryTooLongError()); + var query = GetWorkspaceSymbolQuery(root); var limit = GetLimit(root, DefaultLimit, MaxWorkspaceSymbols, "params", "limit") ?? GetLimit(root, DefaultLimit, MaxWorkspaceSymbols, "params", "maxResults") @@ -141,6 +139,14 @@ private SymbolResponse CreateWorkspaceSymbolResponse( return new SymbolResponse(array, [], array.Count, truncated); } + private static string GetWorkspaceSymbolQuery(JsonElement root) + { + var query = GetRequiredString(root, "params", "query"); + if (query.Length > QueryLimits.MaxQueryLength) + throw new ArgumentException(QueryLimits.FormatQueryTooLongError()); + return query; + } + private IEnumerable EnumerateWorkspaceSymbolItems( IReadOnlyList symbols, CancellationToken cancellationToken) diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index d9abbf035..1483c0972 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -423,6 +423,9 @@ await messages.Writer if (hasId && !LspProtocol.TryParseRequestId(payload, idElement, out id, out var requestIdError)) return Error(null, -32600, requestIdError); + if (!HasValidJsonRpcEnvelope(root)) + return Error(id, JsonRpcInvalidRequestCode, JsonRpcInvalidRequestMessage); + if (!root.TryGetProperty("method", out var methodElement) || methodElement.ValueKind != JsonValueKind.String || methodElement.GetString() is not { } method) @@ -430,6 +433,8 @@ await messages.Writer return hasId ? Error(id, JsonRpcInvalidRequestCode, JsonRpcInvalidRequestMessage) : null; } + ValidateMethodParameters(method, root); + var dispatchAction = reservedSessionAction ?? BeginSessionDispatch(method, hasId); switch (dispatchAction) { @@ -750,6 +755,7 @@ private bool TryReserveInboundSessionDispatch( using var document = BoundedJson.ParseDocument(payload, MaxLspFrameBytes, MaxJsonDepth); var root = document.RootElement; if (root.ValueKind != JsonValueKind.Object + || !HasValidJsonRpcEnvelope(root) || !root.TryGetProperty("method", out var methodElement) || methodElement.ValueKind != JsonValueKind.String || methodElement.GetString() is not { } method) @@ -761,11 +767,12 @@ private bool TryReserveInboundSessionDispatch( if (hasId && !LspProtocol.TryParseRequestId(payload, idElement, out _, out _)) return false; + ValidateMethodParameters(method, root); sessionAction = BeginSessionDispatch(method, hasId); InboundSessionDispatchReservedForTesting?.Invoke(method); return true; } - catch (Exception ex) when (ex is JsonException or InvalidDataException) + catch (Exception ex) when (ex is ArgumentException or JsonException or InvalidDataException) { return false; } @@ -778,6 +785,7 @@ private bool TryHandleCancellationNotification(string payload) using var document = BoundedJson.ParseDocument(payload, MaxLspFrameBytes, MaxJsonDepth); var root = document.RootElement; if (root.ValueKind != JsonValueKind.Object + || !HasValidJsonRpcEnvelope(root) || !root.TryGetProperty("method", out var method) || method.ValueKind != JsonValueKind.String || !string.Equals(method.GetString(), "$/cancelRequest", StringComparison.Ordinal) @@ -848,6 +856,17 @@ private bool TryHandleCancellationNotification(string payload) } var methodName = method.GetString(); + if (!HasValidJsonRpcEnvelope(root)) + return Error(id, JsonRpcInvalidRequestCode, JsonRpcInvalidRequestMessage); + try + { + ValidateMethodParameters(methodName!, root); + } + catch (ArgumentException) + { + return Error(id, JsonRpcInvalidParamsCode, JsonRpcInvalidParamsMessage); + } + if (reservedSessionAction.HasValue) { return reservedSessionAction.Value switch @@ -924,18 +943,32 @@ private static bool TryGetRequestKey(string payload, out string requestKey) { using var document = BoundedJson.ParseDocument(payload, MaxLspFrameBytes, MaxJsonDepth); var root = document.RootElement; - return root.ValueKind == JsonValueKind.Object - && root.TryGetProperty("method", out var method) - && method.ValueKind == JsonValueKind.String - && root.TryGetProperty("id", out var requestId) - && TryGetRequestKey(requestId, out requestKey); + if (root.ValueKind != JsonValueKind.Object + || !HasValidJsonRpcEnvelope(root) + || !root.TryGetProperty("method", out var method) + || method.ValueKind != JsonValueKind.String + || method.GetString() is not { } methodName + || !root.TryGetProperty("id", out var requestId) + || !TryGetRequestKey(requestId, out requestKey)) + { + return false; + } + + ValidateMethodParameters(methodName, root); + return true; } - catch (Exception ex) when (ex is JsonException or InvalidDataException) + catch (Exception ex) when (ex is ArgumentException or JsonException or InvalidDataException) { return false; } } + private static void ValidateMethodParameters(string method, JsonElement root) + { + if (string.Equals(method, "workspace/symbol", StringComparison.Ordinal)) + _ = GetWorkspaceSymbolQuery(root); + } + private static bool TryGetRequestKey(JsonElement requestId, out string requestKey) { requestKey = string.Empty; diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index df3c17679..f1ffe31b2 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -2352,6 +2352,209 @@ public void HandleMessage_WorkspaceSymbol_RejectsOversizedQuery_Issue3128() } } + [Fact] + public void Run_RejectsMalformedEnvelopesAndWorkspaceSymbolQueryTypesBeforeDispatch_Issue5231() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_request_validation"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", "class Needle { }\n"); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + var reservedMethods = new ConcurrentQueue(); + var dispatchedMethods = new ConcurrentQueue(); + var symbolRequestCount = 0; + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot) + { + InboundSessionDispatchReservedForTesting = reservedMethods.Enqueue, + BeforeSessionDispatchForTesting = dispatchedMethods.Enqueue, + BeforeSymbolRequestForTesting = _ => Interlocked.Increment(ref symbolRequestCount), + }; + var activities = new ConcurrentQueue(); + using var listener = CaptureCodeIndexActivities(activities); + using var testActivity = new Activity("lsp-issue-5231-test").Start(); + var expectedTraceId = testActivity.TraceId; + + const string invalidInitialize = + """{"jsonrpc":"1.0","id":0,"method":"initialize","params":{}}"""; + using (var input = new MemoryStream(Encoding.UTF8.GetBytes(Frame(invalidInitialize)))) + using (var output = new MemoryStream()) + { + Assert.Equal(CommandExitCodes.Success, server.Run(input, output)); + var response = Assert.Single(ReadLspMessages(output)).Message; + Assert.Equal(0, response["id"]!.GetValue()); + Assert.Equal(-32600, response["error"]!["code"]!.GetValue()); + Assert.Equal("Invalid Request", response["error"]!["message"]!.GetValue()); + } + + const string initialize = + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"""; + using (var input = new MemoryStream(Encoding.UTF8.GetBytes(Frame(initialize)))) + using (var output = new MemoryStream()) + { + Assert.Equal(CommandExitCodes.Success, server.Run(input, output)); + var response = Assert.Single(ReadLspMessages(output)).Message; + Assert.Equal(1, response["id"]!.GetValue()); + Assert.NotNull(response["result"]); + } + + var invalidEnvelopeOverload = server.CreateOverloadResponse( + """{"jsonrpc":"1.0","id":12,"method":"workspace/symbol","params":{"query":"Needle"}}"""); + Assert.Equal(12L, invalidEnvelopeOverload!["id"]!.GetValue()); + Assert.Equal(-32600, invalidEnvelopeOverload["error"]!["code"]!.GetValue()); + var invalidQueryOverload = server.CreateOverloadResponse( + """{"jsonrpc":"2.0","id":13,"method":"workspace/symbol","params":{"query":null}}"""); + Assert.Equal(13L, invalidQueryOverload!["id"]!.GetValue()); + Assert.Equal(-32602, invalidQueryOverload["error"]!["code"]!.GetValue()); + var validOverload = server.CreateOverloadResponse( + """{"jsonrpc":"2.0","id":14,"method":"workspace/symbol","params":{"query":""}}"""); + Assert.Equal(-32000, validOverload!["error"]!["code"]!.GetValue()); + + string[] requests = + [ + """{"jsonrpc":"2.0","method":"initialized","params":{}}""", + """{"id":2,"method":"workspace/symbol","params":{"query":"Needle"}}""", + """{"jsonrpc":null,"id":3,"method":"workspace/symbol","params":{"query":"Needle"}}""", + """{"jsonrpc":2,"id":4,"method":"workspace/symbol","params":{"query":"Needle"}}""", + """{"jsonrpc":"1.0","id":5,"method":"workspace/symbol","params":{"query":"Needle"}}""", + """{"jsonrpc":"2.0","id":6,"method":"workspace/symbol","params":{}}""", + """{"jsonrpc":"2.0","id":7,"method":"workspace/symbol","params":{"query":null}}""", + """{"jsonrpc":"2.0","id":8,"method":"workspace/symbol","params":{"query":123}}""", + """{"jsonrpc":"2.0","id":9,"method":"workspace/symbol","params":{"query":"Needle"}}""", + """{"jsonrpc":"2.0","id":10,"method":"workspace/symbol","params":{"query":""}}""", + """{"jsonrpc":"2.0","id":11,"method":"shutdown"}""", + """{"jsonrpc":"2.0","method":"exit"}""", + ]; + using (var input = new MemoryStream(Encoding.UTF8.GetBytes(string.Concat(requests.Select(Frame))))) + using (var output = new MemoryStream()) + { + Assert.Equal(CommandExitCodes.Success, server.Run(input, output)); + var messages = ReadLspMessages(output).Select(item => item.Message).ToList(); + Assert.Equal(10, messages.Count); + var responses = messages.ToDictionary(message => message["id"]!.GetValue()); + + foreach (var id in new[] { 2, 3, 4, 5 }) + { + Assert.Equal(id, responses[id]["id"]!.GetValue()); + Assert.Equal(-32600, responses[id]["error"]!["code"]!.GetValue()); + Assert.Equal("Invalid Request", responses[id]["error"]!["message"]!.GetValue()); + } + + foreach (var id in new[] { 6, 7, 8 }) + { + Assert.Equal(id, responses[id]["id"]!.GetValue()); + Assert.Equal(-32602, responses[id]["error"]!["code"]!.GetValue()); + Assert.Equal("Invalid params", responses[id]["error"]!["message"]!.GetValue()); + } + + var nonEmptySymbol = Assert.Single(responses[9]["result"]!.AsArray()); + Assert.Equal("Needle", nonEmptySymbol!["name"]!.GetValue()); + var emptyQuerySymbol = Assert.Single(responses[10]["result"]!.AsArray()); + Assert.Equal("Needle", emptyQuerySymbol!["name"]!.GetValue()); + Assert.Null(responses[11]["result"]); + } + + Assert.Equal(1, reservedMethods.Count(method => method == "initialize")); + Assert.Equal(2, reservedMethods.Count(method => method == "workspace/symbol")); + Assert.Equal(1, dispatchedMethods.Count(method => method == "initialize")); + Assert.Equal(2, dispatchedMethods.Count(method => method == "workspace/symbol")); + Assert.Equal(2, symbolRequestCount); + var requestActivities = activities.Where(activity => + activity.OperationName == "lsp.request" + && activity.TraceId == expectedTraceId).ToList(); + Assert.Equal(1, requestActivities.Count(activity => + Equals(activity.GetTagItem("rpc.method"), "initialize"))); + Assert.Equal(2, requestActivities.Count(activity => + Equals(activity.GetTagItem("rpc.method"), "workspace/symbol"))); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public async Task RunAsync_InvalidEnvelopeCancellationDoesNotCancelActiveRequest_Issue5231() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_invalid_cancel_envelope"); + using var requestEntered = new ManualResetEventSlim(false); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", "class Needle { }\n"); + const string request = + """{"jsonrpc":"2.0","id":"active-5231","method":"workspace/symbol","params":{"query":"Needle"}}"""; + const string invalidCancel = + """{"jsonrpc":"1.0","method":"$/cancelRequest","params":{"id":"active-5231"}}"""; + const string validCancel = + """{"jsonrpc":"2.0","method":"$/cancelRequest","params":{"id":"active-5231"}}"""; + using var input = new StagedReadStream( + Encoding.UTF8.GetBytes(Frame(request)), + Encoding.UTF8.GetBytes(Frame(invalidCancel)), + finalSuffix: Encoding.UTF8.GetBytes(Frame(validCancel))); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + CancellationToken activeRequestCancellation = default; + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot) + { + BeforeSymbolRequestForTesting = cancellationToken => + { + activeRequestCancellation = cancellationToken; + requestEntered.Set(); + Assert.True( + cancellationToken.WaitHandle.WaitOne(TimeSpan.FromSeconds(30)), + "The valid cancellation notification did not cancel the active request."); + cancellationToken.ThrowIfCancellationRequested(); + }, + }; + InitializeSession(server); + using var output = new MemoryStream(); + + try + { + var runTask = server.RunAsync(input, output); + Assert.True(requestEntered.Wait(TestDeterminism.DefaultTimeout)); + + input.ReleaseSuffix(); + await input.WaitForFinalSuffixReadAsync().WaitAsync(TestDeterminism.DefaultTimeout); + Assert.False(activeRequestCancellation.IsCancellationRequested); + Assert.False(runTask.IsCompleted); + + input.ReleaseFinalSuffix(); + Assert.Equal( + CommandExitCodes.Success, + await runTask.WaitAsync(TestDeterminism.DefaultTimeout)); + } + finally + { + input.ReleaseSuffix(); + input.ReleaseFinalSuffix(); + } + + var messages = ReadLspMessages(output); + var invalidEnvelope = Assert.Single( + messages, + entry => entry.Message["id"] == null + && entry.Message["error"]?["code"]?.GetValue() == -32600); + Assert.Equal("Invalid Request", invalidEnvelope.Message["error"]!["message"]!.GetValue()); + var cancelled = Assert.Single( + messages, + entry => entry.Message["id"]?.GetValue() == "active-5231"); + Assert.Equal(-32800, cancelled.Message["error"]!["code"]!.GetValue()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_WorkspaceSymbol_HonorsClientLimit_Issue3537() { @@ -6074,21 +6277,31 @@ private async Task WaitForMarkerReleaseAsync( private sealed class StagedReadStream( byte[] prefix, byte[] suffix, - bool leaveFinalReadPending = false) : Stream + bool leaveFinalReadPending = false, + byte[]? finalSuffix = null) : Stream { private readonly TaskCompletionSource suffixRelease = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource finalSuffixReadStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource finalSuffixRelease = + new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource finalReadStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource finalReadRelease = new(TaskCreationOptions.RunContinuationsAsynchronously); private int prefixOffset; private int suffixOffset; + private int finalSuffixOffset; internal bool FinalReadStarted => finalReadStarted.Task.IsCompleted; internal void ReleaseSuffix() => suffixRelease.TrySetResult(); + internal Task WaitForFinalSuffixReadAsync() => finalSuffixReadStarted.Task; + + internal void ReleaseFinalSuffix() => finalSuffixRelease.TrySetResult(); + public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; @@ -6121,6 +6334,19 @@ public override async ValueTask ReadAsync( await suffixRelease.Task.WaitAsync(cancellationToken); if (suffixOffset >= suffix.Length) { + if (finalSuffix != null) + { + finalSuffixReadStarted.TrySetResult(); + await finalSuffixRelease.Task.WaitAsync(cancellationToken); + if (finalSuffixOffset < finalSuffix.Length) + { + var finalSuffixCount = Math.Min(buffer.Length, finalSuffix.Length - finalSuffixOffset); + finalSuffix.AsMemory(finalSuffixOffset, finalSuffixCount).CopyTo(buffer); + finalSuffixOffset += finalSuffixCount; + return finalSuffixCount; + } + } + if (!leaveFinalReadPending) return 0; @@ -6147,7 +6373,10 @@ public override void Write(byte[] buffer, int offset, int count) protected override void Dispose(bool disposing) { if (disposing) + { + finalSuffixRelease.TrySetResult(); finalReadRelease.TrySetResult(); + } base.Dispose(disposing); } }