diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2ea044278..9264b0872 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -430,6 +430,17 @@ must read that live cache before disk so unsaved editor buffers can identify the requested token, but provider results remain conservative and index-backed: return empty arrays or null when the database cannot answer safely instead of inventing language-server analysis. +`LspServer` uses one lock-protected lifecycle state machine across ordinary +dispatch, the cancellation fast path, and queue-overload responses. Its phases +are before-initialize, initializing, running, shutdown, and exited. Only the +first `initialize` request can enter initialization. The transport reserves each +frame's lifecycle action in receive order. Under the output gate, the serialized +initialize response and the transition to running share one publication boundary: +the state changes immediately before the frame write starts. Shutdown changes +phase before waiting for active dispatches, then disposes an owned query context +and reader exactly once. +After shutdown, requests receive `-32600`, notifications are ignored, and only +the `exit` notification completes the normal lifecycle. Disk-backed position-line caching must enforce its 4 MiB input limit while streaming, not only through a pre-read `Length` check. Bytes beyond the limit must never reach text decoding, including when a shared file grows concurrently, @@ -3627,6 +3638,15 @@ editor integration は標準的な location 形状を直接要求できる。`de request token を特定できるよう disk より先に live cache を読む必要があるが、provider result は 保守的かつ index-backed のままにする。database が安全に答えられない場合は、language-server analysis を作り上げず、空配列または null を返す。 +`LspServer` は通常 dispatch、cancellation fast path、queue-overload response の全経路で、 +1 つの lock 保護された lifecycle state machine を使う。phase は before-initialize、 +initializing、running、shutdown、exited である。最初の `initialize` request だけが初期化へ +遷移できる。transport は各 frame の lifecycle action を受信順で予約する。output gate の下で、 +serialize 済み initialize response と running への遷移は 1 つの公開境界を共有し、frame の +書き込み開始直前に state を変更する。shutdown は active dispatch の完了待ちより先に phase を +変更し、その後に所有する query context と reader を正確に 1 回だけ破棄する。shutdown 後の +request は `-32600` を返し、notification は無視し、`exit` notification だけが正常な lifecycle を +完了させる。 disk 上の position-line cache は、事前の `Length` check だけでなく streaming 中も 4 MiB の input 上限を強制する必要がある。共有 file が同時に増大する場合も上限超過 byte を text decode に 渡してはならず、bounded な failure reason は `position_file_too_large` のままとする。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 7b2b19700..9cda0098c 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2702,6 +2702,14 @@ but do not speak MCP. It also advertises full `textDocument` sync and conservative `hover`, `completion`, `documentHighlight`, `semanticTokens/full`, `codeLens`, and `inlayHint` providers backed by indexed symbols and references where available. +Clients must follow the standard LSP lifecycle: send one `initialize` request +first, optionally send the `initialized` notification after its response, then +send ordinary requests, finish with one `shutdown` request, and finally send the +`exit` notification. Requests received before initialization completes return +JSON-RPC `-32002` (`Server not initialized`). A duplicate `initialize`, or any +request received after `shutdown`, returns `-32600` (`Invalid Request`); +out-of-phase notifications are ignored. Sending `exit` before a successful +`shutdown` terminates the server with a usage error. `textDocument/inlayHint` honors the requested LSP range (including its exclusive end position) and omits type labels when the indexed return type is already written immediately before the symbol name, so explicit field, property, and @@ -5813,6 +5821,12 @@ MCP stdio は line protocol です。LF 区切りの各行に compact な UTF-8 indexed symbols / references で答えられる範囲に限定した `hover`、`completion`、 `documentHighlight`、`semanticTokens/full`、`codeLens`、`inlayHint` provider を advertise します。 +client は標準の LSP lifecycle に従う必要があります。最初に `initialize` request を 1 回だけ +送り、その response 後に必要なら `initialized` notification を送り、通常 request を処理した後、 +`shutdown` request、最後に `exit` notification の順で終了してください。初期化完了前に受信した +request は JSON-RPC `-32002`(`Server not initialized`)を返します。重複した `initialize`、 +または `shutdown` 後の request は `-32600`(`Invalid Request`)を返し、順序外の notification は +無視します。成功した `shutdown` より前に `exit` を送ると、server は usage error で終了します。 `textDocument/inlayHint` は end position を含まない requested LSP range を尊重し、 indexed return type が symbol name の直前にすでに明記されている場合は type label を 省略するため、field / property / method の明示型を hint として重複表示しません。 diff --git a/changelog.d/unreleased/4849.fixed.md b/changelog.d/unreleased/4849.fixed.md new file mode 100644 index 000000000..58d97874d --- /dev/null +++ b/changelog.d/unreleased/4849.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 4849 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - src/CodeIndex/Lsp/LspServer.Protocol.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - tests/CodeIndex.Tests/LspServerBudgetTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **LSP sessions now enforce the initialize-to-exit lifecycle (#4849)** — Requests before initialization return `ServerNotInitialized`, duplicate initialization and post-shutdown requests return `InvalidRequest`, and out-of-phase notifications are ignored. Shutdown waits for active dispatches and disposes owned query resources exactly once, preventing the #2830 regression across direct, cancellation, and overload dispatch paths. + +## 日本語 + +- **LSP session で initialize から exit までの lifecycle を強制するようにしました (#4849)** — 初期化前の request は `ServerNotInitialized`、重複初期化と shutdown 後の request は `InvalidRequest` を返し、順序外の notification は無視します。shutdown は active dispatch の完了を待ち、所有する query resource を正確に 1 回だけ破棄するため、direct、cancellation、overload の各 dispatch 経路で #2830 の regression を防ぎます。 diff --git a/src/CodeIndex/Lsp/LspServer.Protocol.cs b/src/CodeIndex/Lsp/LspServer.Protocol.cs index 7c00740b9..c3be3320d 100644 --- a/src/CodeIndex/Lsp/LspServer.Protocol.cs +++ b/src/CodeIndex/Lsp/LspServer.Protocol.cs @@ -313,12 +313,14 @@ private async Task WriteResponseMessageAsync( Stream output, SemaphoreSlim outputGate, JsonObject response, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Action? responsePublicationStarting = null) { await outputGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { var payload = response.ToJsonString(_jsonOptions); + responsePublicationStarting?.Invoke(); if (await LspProtocol.TryWriteMessageAsync(output, payload, cancellationToken).ConfigureAwait(false)) return; @@ -360,12 +362,27 @@ internal static bool TryWriteMessage(Stream output, string payload, out int body public void Dispose() { - _ = _shutdownRequested; - if (_ownedQueryDb != null) + lock (_sessionStateGate) { - _reader.Dispose(); - _ownedQueryDb.Dispose(); - _ownedQueryDb = null; + _sessionState = LspSessionState.Exited; + while (_activeSessionDispatches != 0) + Monitor.Wait(_sessionStateGate); } + + DisposeOwnedResourcesOnce(); + } + + private void DisposeOwnedResourcesOnce() + { + if (Interlocked.Exchange(ref _ownedResourcesDisposed, 1) != 0) + return; + + var ownedQueryDb = Interlocked.Exchange(ref _ownedQueryDb, null); + if (ownedQueryDb == null) + return; + + _reader.Dispose(); + ownedQueryDb.Dispose(); + Interlocked.Increment(ref _ownedResourceDisposeCount); } } diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index f6954264c..06773b1fb 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -57,13 +57,17 @@ internal static Action? PositionFileLengthCheckedForTesting internal const int MaxDocumentPathFallbackCandidates = 32; internal const int MaxUnknownMethodDiagnosticChars = 240; private const int JsonRpcInvalidParamsCode = -32602; + private const int JsonRpcInvalidRequestCode = -32600; private const int JsonRpcInternalErrorCode = -32603; private const int JsonRpcRequestCancelledCode = -32800; private const int JsonRpcServerBusyCode = -32000; + internal const int LspServerNotInitializedCode = -32002; + private const string JsonRpcInvalidRequestMessage = "Invalid Request"; private const string JsonRpcInvalidParamsMessage = "Invalid params"; private const string JsonRpcInternalErrorMessage = "Internal error"; private const string JsonRpcRequestCancelledMessage = "Request cancelled"; private const string JsonRpcServerBusyMessage = "Server busy"; + private const string LspServerNotInitializedMessage = "Server not initialized"; private const string LspLookupFailureEventName = "lsp.lookup_failed"; private const string LspLookupFailureReasonTag = "lsp.lookup.failure_reason"; private const string LspMethodTag = "lsp.method"; @@ -135,19 +139,29 @@ internal static Action? PositionFileLengthCheckedForTesting private readonly JsonSerializerOptions _jsonOptions; private readonly string? _projectRoot; private readonly StringComparison _pathStringComparison; - private bool _shutdownRequested; - private bool _exitRequested; - private bool _exitRequestedBeforeShutdown; + private readonly object _sessionStateGate = new(); + private LspSessionState _sessionState; + private int _activeSessionDispatches; + private int _ownedResourcesDisposed; + private int _ownedResourceDisposeCount; + private volatile bool _exitRequested; + private volatile bool _exitRequestedBeforeShutdown; private readonly List _workspaceFolders = []; private readonly LspLiveDocumentStore _liveDocumentStore; private readonly ConcurrentDictionary _requestCancellations = new(StringComparer.Ordinal); private long _contentChangeEntriesDropped; + internal Action? InboundSessionDispatchReservedForTesting { get; set; } + internal Action? BeforeSessionDispatchForTesting { get; set; } internal Action? BeforeSymbolRequestForTesting { get; set; } private readonly record struct PositionTokenContext(string Token, string ResolvedPath, string IndexedPath, string? WorkspaceRoot, int Line, int StartCharacter, int EndCharacter); private readonly record struct DocumentSymbolNode(SymbolResult Symbol, JsonObject Item); private readonly record struct IndexedDocumentContext(string DocumentPath, string ResolvedPath, string IndexedPath, string? WorkspaceRoot); - private readonly record struct InboundMessage(string Payload, string? RequestKey, CancellationTokenSource? RequestCancellation); + private readonly record struct InboundMessage( + string Payload, + string? RequestKey, + CancellationTokenSource? RequestCancellation, + SessionDispatchAction? SessionAction); private readonly record struct SymbolResponse( JsonArray FinalItems, IEnumerable PartialItems, @@ -162,6 +176,27 @@ internal readonly record struct LspMessageReadDiagnostic( int? ContentLength = null, int? MaxContentLength = null); + private enum LspSessionState + { + BeforeInitialize, + Initializing, + Running, + Shutdown, + Exited, + } + + private enum SessionDispatchAction + { + Dispatch, + Initialize, + Ignore, + Shutdown, + Exit, + ExitBeforeShutdown, + ServerNotInitialized, + InvalidRequest, + } + public LspServer(DbReader reader, string version, JsonSerializerOptions jsonOptions, string? projectRoot = null) { _reader = reader; @@ -207,6 +242,17 @@ internal LspServer( internal long ContentChangeEntriesDroppedForTests => _contentChangeEntriesDropped; + internal int OwnedResourceDisposeCountForTests => Volatile.Read(ref _ownedResourceDisposeCount); + + internal bool ShutdownStartedForTests + { + get + { + lock (_sessionStateGate) + return _sessionState is LspSessionState.Shutdown or LspSessionState.Exited; + } + } + /// /// Compatibility wrapper that runs without caller cancellation. Prefer /// when the caller has a shutdown or disconnect token. @@ -263,14 +309,21 @@ public async Task RunAsync(Stream input, Stream output, CancellationToken c if (TryHandleCancellationNotification(read.Payload)) continue; - var inbound = CreateInboundMessage(read.Payload, cancellationToken); + SessionDispatchAction? reservedSessionAction = null; + if (TryReserveInboundSessionDispatch(read.Payload, out var sessionAction)) + reservedSessionAction = sessionAction; + + var inbound = CreateInboundMessage( + read.Payload, + cancellationToken, + reservedSessionAction); if (messages.Writer.TryWrite(inbound)) continue; - var busyResponse = CreateServerBusyResponse(read.Payload); + var busyResponse = CreateOverloadResponse(read.Payload, reservedSessionAction); if (busyResponse != null) { - ReleaseInboundMessage(inbound); + AbandonInboundMessage(inbound); await overloadResponses.Writer .WriteAsync(busyResponse, readCancellation.Token) .ConfigureAwait(false); @@ -285,7 +338,7 @@ await messages.Writer } catch { - ReleaseInboundMessage(inbound); + AbandonInboundMessage(inbound); throw; } } @@ -314,12 +367,17 @@ await messages.Writer } internal JsonObject? HandleMessage(string payload) => - HandleMessage(payload, outbound: null, CancellationToken.None); + HandleMessage( + payload, + outbound: null, + CancellationToken.None, + reservedSessionAction: null); private JsonObject? HandleMessage( string payload, Action? outbound, - CancellationToken requestCancellation) + CancellationToken requestCancellation, + SessionDispatchAction? reservedSessionAction) { // Run() normally obtains payloads through TryReadMessage, but internal callers can bypass // that frame reader; keep the JSON parse under the same byte budget either way. @@ -347,49 +405,103 @@ await messages.Writer 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); if (hasId && !LspProtocol.TryParseRequestId(payload, idElement, out id, out var requestIdError)) return Error(null, -32600, requestIdError); - if (method == null) - return hasId ? Error(id, -32600, "Invalid Request") : null; + if (!root.TryGetProperty("method", out var methodElement) + || methodElement.ValueKind != JsonValueKind.String + || methodElement.GetString() is not { } method) + { + return hasId ? Error(id, JsonRpcInvalidRequestCode, JsonRpcInvalidRequestMessage) : null; + } - RefreshOwnedQuerySnapshot(); - using var activity = StartLspRequestActivity(method); - return method switch + var dispatchAction = reservedSessionAction ?? BeginSessionDispatch(method, hasId); + switch (dispatchAction) { - "initialize" => HandleInitialize(id, root), - "initialized" => null, - "shutdown" => HandleShutdown(id), - "exit" => HandleExit(), - "workspace/didChangeWorkspaceFolders" => HandleDidChangeWorkspaceFolders(root), - "textDocument/didOpen" => HandleDidOpenTextDocument(root), - "textDocument/didChange" => HandleDidChangeTextDocument(root), - "textDocument/didClose" => HandleDidCloseTextDocument(root), - "$/cancelRequest" => null, - "workspace/symbol" => HandleSymbolRequest( - id, - root, - documentSymbols: false, - outbound, - requestCancellation), - "textDocument/documentSymbol" => HandleSymbolRequest( - id, - root, - documentSymbols: true, - outbound, - requestCancellation), - "textDocument/definition" => Result(id, Definition(root, "textDocument/definition")), - "textDocument/declaration" => Result(id, Definition(root, "textDocument/declaration")), - "textDocument/references" => Result(id, References(root, "textDocument/references")), - "textDocument/hover" => Result(id, Hover(root, "textDocument/hover")), - "textDocument/completion" => Result(id, Completion(root, "textDocument/completion")), - "textDocument/documentHighlight" => Result(id, DocumentHighlight(root, "textDocument/documentHighlight")), - "textDocument/semanticTokens/full" => Result(id, SemanticTokensFull(root)), - "textDocument/inlayHint" => Result(id, InlayHint(root)), - _ => hasId ? Error(id, -32601, $"Method not found: {SanitizeUnknownMethod(method)}") : null, - }; + case SessionDispatchAction.Ignore: + return null; + case SessionDispatchAction.Exit: + return HandleExit(exitBeforeShutdown: false); + case SessionDispatchAction.ExitBeforeShutdown: + return HandleExit(exitBeforeShutdown: true); + case SessionDispatchAction.ServerNotInitialized: + return Error(id, LspServerNotInitializedCode, LspServerNotInitializedMessage); + case SessionDispatchAction.InvalidRequest: + return Error(id, JsonRpcInvalidRequestCode, JsonRpcInvalidRequestMessage); + case SessionDispatchAction.Shutdown: + return HandleShutdown(id); + } + + var initializationHandled = false; + var deferInitializationCompletion = + reservedSessionAction == SessionDispatchAction.Initialize; + try + { + BeforeSessionDispatchForTesting?.Invoke(method); + RefreshOwnedQuerySnapshot(); + using var activity = StartLspRequestActivity(method); + var response = method switch + { + "initialize" => HandleInitialize(id, root), + "initialized" => null, + "exit" => null, + "workspace/didChangeWorkspaceFolders" => HandleDidChangeWorkspaceFolders(root), + "textDocument/didOpen" => HandleDidOpenTextDocument(root), + "textDocument/didChange" => HandleDidChangeTextDocument(root), + "textDocument/didClose" => HandleDidCloseTextDocument(root), + "$/cancelRequest" => HandleCancellationNotification(root), + "workspace/symbol" => HandleSymbolRequest( + id, + root, + documentSymbols: false, + outbound, + requestCancellation), + "textDocument/documentSymbol" => HandleSymbolRequest( + id, + root, + documentSymbols: true, + outbound, + requestCancellation), + "textDocument/definition" => Result(id, Definition(root, "textDocument/definition")), + "textDocument/declaration" => Result(id, Definition(root, "textDocument/declaration")), + "textDocument/references" => Result(id, References(root, "textDocument/references")), + "textDocument/hover" => Result(id, Hover(root, "textDocument/hover")), + "textDocument/completion" => Result(id, Completion(root, "textDocument/completion")), + "textDocument/documentHighlight" => Result(id, DocumentHighlight(root, "textDocument/documentHighlight")), + "textDocument/semanticTokens/full" => Result(id, SemanticTokensFull(root)), + "textDocument/inlayHint" => Result(id, InlayHint(root)), + _ => hasId ? Error(id, -32601, $"Method not found: {SanitizeUnknownMethod(method)}") : null, + }; + + if (string.Equals(method, "initialize", StringComparison.Ordinal)) + { + if (!deferInitializationCompletion) + CompleteInitialization(); + initializationHandled = true; + } + + return response; + } + catch (Exception ex) when (ex is ArgumentException or JsonException) + { + return hasId ? Error(id, JsonRpcInvalidParamsCode, JsonRpcInvalidParamsMessage) : null; + } + catch (Exception ex) when (ex is InvalidOperationException or IOException) + { + return hasId ? Error(id, JsonRpcInternalErrorCode, JsonRpcInternalErrorMessage) : null; + } + finally + { + if (string.Equals(method, "initialize", StringComparison.Ordinal) + && !initializationHandled + && !deferInitializationCompletion) + { + AbortInitialization(); + } + + EndSessionDispatch(); + } } catch (Exception ex) when (ex is ArgumentException or JsonException) { @@ -413,6 +525,7 @@ private async Task ProcessInboundMessagesAsync( { await foreach (var inbound in reader.ReadAllAsync().ConfigureAwait(false)) { + var initializeStateSettled = false; try { var notifications = Channel.CreateBounded( @@ -443,7 +556,8 @@ private async Task ProcessInboundMessagesAsync( .AsTask() .GetAwaiter() .GetResult(), - processingCancellation.Token); + processingCancellation.Token, + inbound.SessionAction); } finally { @@ -453,15 +567,43 @@ private async Task ProcessInboundMessagesAsync( if (response != null) { + Action? responsePublicationStarting = null; + if (inbound.SessionAction == SessionDispatchAction.Initialize) + { + var initializationSucceeded = response["error"] == null; + responsePublicationStarting = () => + { + if (initializationSucceeded) + CompleteInitialization(); + else + AbortInitialization(); + initializeStateSettled = true; + }; + } + await WriteResponseMessageAsync( output, outputGate, response, - serverCancellation).ConfigureAwait(false); + serverCancellation, + responsePublicationStarting).ConfigureAwait(false); + } + + if (inbound.SessionAction == SessionDispatchAction.Initialize + && !initializeStateSettled) + { + AbortInitialization(); + initializeStateSettled = true; } } finally { + if (inbound.SessionAction == SessionDispatchAction.Initialize + && !initializeStateSettled) + { + AbortInitialization(); + } + ReleaseInboundMessage(inbound); } @@ -480,7 +622,7 @@ await WriteResponseMessageAsync( finally { while (reader.TryRead(out var pending)) - ReleaseInboundMessage(pending); + AbandonInboundMessage(pending); } } @@ -537,19 +679,22 @@ await WriteResponseMessageAsync( } } - private InboundMessage CreateInboundMessage(string payload, CancellationToken serverCancellation) + private InboundMessage CreateInboundMessage( + string payload, + CancellationToken serverCancellation, + SessionDispatchAction? sessionAction) { if (!TryGetRequestKey(payload, out var requestKey)) - return new InboundMessage(payload, null, null); + return new InboundMessage(payload, null, null, sessionAction); var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(serverCancellation); if (!_requestCancellations.TryAdd(requestKey, requestCancellation)) { requestCancellation.Dispose(); - return new InboundMessage(payload, null, null); + return new InboundMessage(payload, null, null, sessionAction); } - return new InboundMessage(payload, requestKey, requestCancellation); + return new InboundMessage(payload, requestKey, requestCancellation, sessionAction); } private void ReleaseInboundMessage(InboundMessage inbound) @@ -567,6 +712,50 @@ private void ReleaseInboundMessage(InboundMessage inbound) inbound.RequestCancellation.Dispose(); } + private void AbandonInboundMessage(InboundMessage inbound) + { + ReleaseInboundMessage(inbound); + if (inbound.SessionAction is SessionDispatchAction.Dispatch + or SessionDispatchAction.Initialize) + { + EndSessionDispatch(); + } + } + + private bool TryReserveInboundSessionDispatch( + string payload, + out SessionDispatchAction sessionAction) + { + sessionAction = default; + if (payload.Length > MaxLspFrameBytes || Encoding.UTF8.GetByteCount(payload) > MaxLspFrameBytes) + return false; + + try + { + using var document = BoundedJson.ParseDocument(payload, MaxLspFrameBytes, MaxJsonDepth); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("method", out var methodElement) + || methodElement.ValueKind != JsonValueKind.String + || methodElement.GetString() is not { } method) + { + return false; + } + + var hasId = root.TryGetProperty("id", out var idElement); + if (hasId && !LspProtocol.TryParseRequestId(payload, idElement, out _, out _)) + return false; + + sessionAction = BeginSessionDispatch(method, hasId); + InboundSessionDispatchReservedForTesting?.Invoke(method); + return true; + } + catch (Exception ex) when (ex is JsonException or InvalidDataException) + { + return false; + } + } + private bool TryHandleCancellationNotification(string payload) { try @@ -576,24 +765,23 @@ private bool TryHandleCancellationNotification(string payload) if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("method", out var method) || method.ValueKind != JsonValueKind.String - || !string.Equals(method.GetString(), "$/cancelRequest", StringComparison.Ordinal)) + || !string.Equals(method.GetString(), "$/cancelRequest", StringComparison.Ordinal) + || root.TryGetProperty("id", out _)) { return false; } - if (TryGet(root, out var requestId, "params", "id") - && TryGetRequestKey(requestId, out var requestKey) - && _requestCancellations.TryGetValue(requestKey, out var requestCancellation)) + var dispatchAction = BeginSessionDispatch("$/cancelRequest", hasId: false); + if (dispatchAction != SessionDispatchAction.Dispatch) + return true; + + try { - try - { - requestCancellation.Cancel(); - } - catch (ObjectDisposedException) - { - // The request completed while cancellation was being dispatched. - // cancellation dispatch 中に request が完了した。 - } + HandleCancellationNotification(root); + } + finally + { + EndSessionDispatch(); } return true; @@ -604,7 +792,32 @@ private bool TryHandleCancellationNotification(string payload) } } - private static JsonObject? CreateServerBusyResponse(string payload) + private JsonObject? HandleCancellationNotification(JsonElement root) + { + if (TryGet(root, out var requestId, "params", "id") + && TryGetRequestKey(requestId, out var requestKey) + && _requestCancellations.TryGetValue(requestKey, out var requestCancellation)) + { + try + { + requestCancellation.Cancel(); + } + catch (ObjectDisposedException) + { + // The request completed while cancellation was being dispatched. + // cancellation dispatch 中に request が完了した。 + } + } + + return null; + } + + internal JsonObject? CreateOverloadResponse(string payload) => + CreateOverloadResponse(payload, reservedSessionAction: null); + + private JsonObject? CreateOverloadResponse( + string payload, + SessionDispatchAction? reservedSessionAction) { try { @@ -619,7 +832,69 @@ private bool TryHandleCancellationNotification(string payload) return null; } - return Error(id, JsonRpcServerBusyCode, JsonRpcServerBusyMessage); + var methodName = method.GetString(); + if (reservedSessionAction.HasValue) + { + return reservedSessionAction.Value switch + { + SessionDispatchAction.ServerNotInitialized => + Error(id, LspServerNotInitializedCode, LspServerNotInitializedMessage), + SessionDispatchAction.InvalidRequest => + Error(id, JsonRpcInvalidRequestCode, JsonRpcInvalidRequestMessage), + SessionDispatchAction.Dispatch => + Error(id, JsonRpcServerBusyCode, JsonRpcServerBusyMessage), + _ => null, + }; + } + + lock (_sessionStateGate) + { + if (string.Equals(methodName, "initialize", StringComparison.Ordinal)) + { + return _sessionState switch + { + LspSessionState.BeforeInitialize => null, + LspSessionState.Exited => null, + _ => Error(id, JsonRpcInvalidRequestCode, JsonRpcInvalidRequestMessage), + }; + } + + if (string.Equals(methodName, "shutdown", StringComparison.Ordinal)) + { + return _sessionState switch + { + LspSessionState.BeforeInitialize or LspSessionState.Initializing => + Error(id, LspServerNotInitializedCode, LspServerNotInitializedMessage), + LspSessionState.Running => null, + LspSessionState.Shutdown => + Error(id, JsonRpcInvalidRequestCode, JsonRpcInvalidRequestMessage), + _ => null, + }; + } + + if (string.Equals(methodName, "exit", StringComparison.Ordinal)) + { + return _sessionState switch + { + LspSessionState.BeforeInitialize or LspSessionState.Initializing => + Error(id, LspServerNotInitializedCode, LspServerNotInitializedMessage), + LspSessionState.Running or LspSessionState.Shutdown => + Error(id, JsonRpcInvalidRequestCode, JsonRpcInvalidRequestMessage), + _ => null, + }; + } + + return _sessionState switch + { + LspSessionState.BeforeInitialize or LspSessionState.Initializing => + Error(id, LspServerNotInitializedCode, LspServerNotInitializedMessage), + LspSessionState.Running => + Error(id, JsonRpcServerBusyCode, JsonRpcServerBusyMessage), + LspSessionState.Shutdown => + Error(id, JsonRpcInvalidRequestCode, JsonRpcInvalidRequestMessage), + _ => null, + }; + } } catch (Exception ex) when (ex is JsonException or InvalidDataException) { @@ -721,17 +996,154 @@ private static string AppendEllipsisIfNeeded(string value, bool wasTruncated) private JsonObject HandleShutdown(JsonNode? id) { - _shutdownRequested = true; + WaitForActiveSessionDispatches(); + DisposeOwnedResourcesOnce(); return Result(id, null); } - private JsonObject? HandleExit() + private SessionDispatchAction BeginSessionDispatch(string method, bool hasId) + { + lock (_sessionStateGate) + { + switch (_sessionState) + { + case LspSessionState.BeforeInitialize: + if (string.Equals(method, "initialize", StringComparison.Ordinal)) + { + if (!hasId) + return SessionDispatchAction.Ignore; + + _sessionState = LspSessionState.Initializing; + _activeSessionDispatches++; + return SessionDispatchAction.Initialize; + } + + if (string.Equals(method, "exit", StringComparison.Ordinal) && !hasId) + { + _sessionState = LspSessionState.Exited; + return SessionDispatchAction.ExitBeforeShutdown; + } + + return hasId + ? SessionDispatchAction.ServerNotInitialized + : SessionDispatchAction.Ignore; + + case LspSessionState.Initializing: + if (string.Equals(method, "initialize", StringComparison.Ordinal)) + { + return hasId + ? SessionDispatchAction.InvalidRequest + : SessionDispatchAction.Ignore; + } + + if (string.Equals(method, "exit", StringComparison.Ordinal) && !hasId) + { + _sessionState = LspSessionState.Exited; + return SessionDispatchAction.ExitBeforeShutdown; + } + + return hasId + ? SessionDispatchAction.ServerNotInitialized + : SessionDispatchAction.Ignore; + + case LspSessionState.Running: + if (string.Equals(method, "initialize", StringComparison.Ordinal)) + { + return hasId + ? SessionDispatchAction.InvalidRequest + : SessionDispatchAction.Ignore; + } + + if (string.Equals(method, "$/cancelRequest", StringComparison.Ordinal) + && hasId) + { + return SessionDispatchAction.InvalidRequest; + } + + if (string.Equals(method, "shutdown", StringComparison.Ordinal)) + { + if (!hasId) + return SessionDispatchAction.Ignore; + + _sessionState = LspSessionState.Shutdown; + return SessionDispatchAction.Shutdown; + } + + if (string.Equals(method, "exit", StringComparison.Ordinal)) + { + if (hasId) + return SessionDispatchAction.InvalidRequest; + + _sessionState = LspSessionState.Exited; + return SessionDispatchAction.ExitBeforeShutdown; + } + + _activeSessionDispatches++; + return SessionDispatchAction.Dispatch; + + case LspSessionState.Shutdown: + if (string.Equals(method, "exit", StringComparison.Ordinal) && !hasId) + { + _sessionState = LspSessionState.Exited; + return SessionDispatchAction.Exit; + } + + return hasId + ? SessionDispatchAction.InvalidRequest + : SessionDispatchAction.Ignore; + + default: + return SessionDispatchAction.Ignore; + } + } + } + + private JsonObject? HandleExit(bool exitBeforeShutdown) { - _exitRequestedBeforeShutdown = !_shutdownRequested; + _exitRequestedBeforeShutdown = exitBeforeShutdown; _exitRequested = true; return null; } + private void CompleteInitialization() + { + lock (_sessionStateGate) + { + if (_sessionState == LspSessionState.Initializing) + _sessionState = LspSessionState.Running; + } + } + + private void AbortInitialization() + { + lock (_sessionStateGate) + { + if (_sessionState == LspSessionState.Initializing) + _sessionState = LspSessionState.BeforeInitialize; + } + } + + private void EndSessionDispatch() + { + lock (_sessionStateGate) + { + _activeSessionDispatches--; + if (_activeSessionDispatches < 0) + throw new InvalidOperationException("LSP session dispatch count became negative."); + if (_activeSessionDispatches == 0) + Monitor.PulseAll(_sessionStateGate); + } + } + + private void WaitForActiveSessionDispatches() + { + lock (_sessionStateGate) + { + while (_activeSessionDispatches != 0) + Monitor.Wait(_sessionStateGate); + } + } + private JsonObject HandleInitialize(JsonNode? id, JsonElement root) { CaptureInitializeWorkspaceFolders(root); diff --git a/tests/CodeIndex.Tests/LspServerBudgetTests.cs b/tests/CodeIndex.Tests/LspServerBudgetTests.cs index bcaf114f6..f7664ab89 100644 --- a/tests/CodeIndex.Tests/LspServerBudgetTests.cs +++ b/tests/CodeIndex.Tests/LspServerBudgetTests.cs @@ -28,6 +28,7 @@ public void HandleMessage_LiveDocumentSync_EvictsOldestBufferWhenAggregateBudget var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + Initialize(server); var text = new string('x', LspServer.MaxPositionDocumentBytes); for (var i = 0; i < 5; i++) { @@ -59,6 +60,7 @@ public void HandleMessage_LiveDocumentSync_UsesLatestTextWhenContentChangesAreOv TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", diskSource); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + Initialize(server); var contentChanges = Enumerable.Range(0, LspServer.MaxContentChangesPerNotification + 5) .Select(i => new { text = i == LspServer.MaxContentChangesPerNotification + 4 ? latestSource : diskSource }) .ToArray(); @@ -109,6 +111,7 @@ public void HandleMessage_Hover_UsesWorkspaceRelativePath_Issue3817() MarkGraphReady(dbPath); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + Initialize(server); var hover = server.HandleMessage(CreatePositionRequest( "textDocument/hover", @@ -142,6 +145,7 @@ public void HandleMessage_Hover_RedactsAbsolutePathWithoutWorkspaceRoot_Issue381 MarkGraphReady(dbPath); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions()); + Initialize(server); var hover = server.HandleMessage(CreatePositionRequest( "textDocument/hover", @@ -164,6 +168,14 @@ public void HandleMessage_Hover_RedactsAbsolutePathWithoutWorkspaceRoot_Issue381 private static string CreateDefinitionRequest(string sourcePath, int id, int line, int character) => CreatePositionRequest("textDocument/definition", sourcePath, id, line, character); + private static void Initialize(LspServer server) + { + var response = server.HandleMessage( + """{"jsonrpc":"2.0","id":"__test_initialize__","method":"initialize","params":{}}"""); + Assert.NotNull(response); + Assert.Null(response!["error"]); + } + private static string CreatePositionRequest(string method, string sourcePath, int id, int line, int character) => JsonSerializer.Serialize(new { diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 945a156ed..305e2fd32 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Globalization; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -14,6 +15,8 @@ namespace CodeIndex.Tests; public class LspServerTests { + private static readonly ConditionalWeakTable InitializedServers = new(); + [Fact] public void ExtractTokenAtUtf16Position_ReturnsIdentifierUnderCursor() { @@ -336,6 +339,442 @@ public void HandleMessage_Initialize_AdvertisesCoreCapabilities() } } + [Fact] + public void HandleMessage_EnforcesLifecycleOrdering_Issue4849() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_lifecycle"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot); + const string symbolRequest = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"workspace/symbol\",\"params\":{\"query\":\"Needle\"}}"; + + Assert.Null(server.HandleMessage("{\"jsonrpc\":\"2.0\",\"method\":\"initialized\",\"params\":{}}")); + var beforeInitialize = server.HandleMessage(symbolRequest); + Assert.Equal( + LspServer.LspServerNotInitializedCode, + beforeInitialize!["error"]!["code"]!.GetValue()); + Assert.Equal( + LspServer.LspServerNotInitializedCode, + server.CreateOverloadResponse(symbolRequest)!["error"]!["code"]!.GetValue()); + + Assert.Null(server.HandleMessage("{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{}}")); + var stillBeforeInitialize = server.HandleMessage(symbolRequest); + Assert.Equal( + LspServer.LspServerNotInitializedCode, + stillBeforeInitialize!["error"]!["code"]!.GetValue()); + + var initialize = server.HandleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"initialize\",\"params\":{}}"); + Assert.NotNull(initialize); + Assert.Null(initialize!["error"]); + Assert.Null(server.HandleMessage("{\"jsonrpc\":\"2.0\",\"method\":\"initialized\",\"params\":{}}")); + Assert.Equal( + -32000, + server.CreateOverloadResponse(symbolRequest)!["error"]!["code"]!.GetValue()); + Assert.Null(server.CreateOverloadResponse( + "{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"shutdown\"}")); + var idBearingCancellation = server.HandleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":21,\"method\":\"$/cancelRequest\",\"params\":{\"id\":1}}"); + Assert.Equal(-32600, idBearingCancellation!["error"]!["code"]!.GetValue()); + + var duplicateInitialize = server.HandleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"initialize\",\"params\":{}}"); + Assert.Equal(-32600, duplicateInitialize!["error"]!["code"]!.GetValue()); + + Assert.Null(server.HandleMessage("{\"jsonrpc\":\"2.0\",\"method\":\"shutdown\"}")); + var runningResponse = server.HandleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"unknown\"}"); + Assert.Equal(-32601, runningResponse!["error"]!["code"]!.GetValue()); + + var shutdown = server.HandleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"shutdown\"}"); + Assert.NotNull(shutdown); + Assert.Null(shutdown!["result"]); + + Assert.Null(server.HandleMessage( + "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didOpen\",\"params\":{}}")); + var afterShutdown = server.HandleMessage(symbolRequest); + Assert.Equal(-32600, afterShutdown!["error"]!["code"]!.GetValue()); + Assert.Equal( + -32600, + server.CreateOverloadResponse(symbolRequest)!["error"]!["code"]!.GetValue()); + + Assert.Null(server.HandleMessage("{\"jsonrpc\":\"2.0\",\"method\":\"exit\"}")); + Assert.Null(server.HandleMessage(symbolRequest)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public async Task HandleMessage_ConcurrentInitialize_AllowsExactlyOne_Issue4849() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_concurrent_initialize"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot); + var requests = Enumerable.Range(1, 2) + .Select>( + id => () => server.HandleMessage( + $"{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":\"initialize\",\"params\":{{}}}}")); + + var responses = await TestDeterminism.RunConcurrentlyAsync(requests); + + Assert.Single(responses, response => response!["error"] == null); + Assert.Single( + responses, + response => response!["error"] is JsonNode error + && error["code"]!.GetValue() == -32600); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public async Task HandleMessage_ShutdownWaitsForActiveRequestAndDisposesOwnedResourcesOnce_Issue4849() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_concurrent_shutdown"); + using var entered = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var queryDb = new DbContext(DbOpenIntent.QueryOnly, dbPath); + using var server = new LspServer( + queryDb, + dbPath, + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot) + { + BeforeSessionDispatchForTesting = method => + { + if (!string.Equals(method, "workspace/symbol", StringComparison.Ordinal)) + return; + + entered.Set(); + release.Wait(); + }, + }; + InitializeSession(server); + + var requestTask = Task.Factory.StartNew( + () => server.HandleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"workspace/symbol\",\"params\":{\"query\":\"Needle\"}}"), + CancellationToken.None, + TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default); + Assert.True(entered.Wait(TestDeterminism.DefaultTimeout)); + + var shutdownTask = Task.Run(() => server.HandleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"shutdown\"}")); + try + { + await TestDeterminism.WaitUntilAsync( + () => server.ShutdownStartedForTests, + "the LSP shutdown transition"); + await TestDeterminism.AssertTaskRemainsBlockedAsync(shutdownTask); + } + finally + { + release.Set(); + } + + Assert.NotNull(await requestTask.WaitAsync(TestDeterminism.DefaultTimeout)); + var shutdown = await shutdownTask.WaitAsync(TestDeterminism.DefaultTimeout); + Assert.NotNull(shutdown); + Assert.Null(shutdown!["result"]); + Assert.Equal(1, server.OwnedResourceDisposeCountForTests); + + server.Dispose(); + Assert.Equal(1, server.OwnedResourceDisposeCountForTests); + } + finally + { + release.Set(); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public async Task RunAsync_PipelinedMessagesUseReceiveTimeLifecycleState_Issue4849() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_pipelined_lifecycle"); + using var pipelinedMessagesReserved = new CountdownEvent(2); + using var initializeDispatchEntered = new ManualResetEventSlim(false); + using var initializeDispatchRelease = new ManualResetEventSlim(false); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "pipelined.cs"); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot) + { + InboundSessionDispatchReservedForTesting = method => + { + if (string.Equals(method, "workspace/symbol", StringComparison.Ordinal) + || string.Equals(method, "textDocument/didOpen", StringComparison.Ordinal)) + { + pipelinedMessagesReserved.Signal(); + } + }, + BeforeSessionDispatchForTesting = method => + { + if (!string.Equals(method, "initialize", StringComparison.Ordinal)) + return; + + initializeDispatchEntered.Set(); + initializeDispatchRelease.Wait(); + }, + }; + var didOpen = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + method = "textDocument/didOpen", + @params = new + { + textDocument = new + { + uri = new Uri(sourcePath).AbsoluteUri, + text = "class Pipelined { }\n", + }, + }, + }); + using var input = new StagedReadStream( + Encoding.UTF8.GetBytes( + Frame("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")), + Encoding.UTF8.GetBytes( + Frame("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"workspace/symbol\",\"params\":{\"query\":\"Needle\"}}") + + Frame(didOpen))); + using var output = new MemoryStream(); + + try + { + var runTask = server.RunAsync(input, output); + Assert.True(initializeDispatchEntered.Wait(TestDeterminism.DefaultTimeout)); + input.ReleaseSuffix(); + Assert.True(pipelinedMessagesReserved.Wait(TestDeterminism.DefaultTimeout)); + initializeDispatchRelease.Set(); + + Assert.Equal( + CommandExitCodes.Success, + await runTask.WaitAsync(TestDeterminism.DefaultTimeout)); + } + finally + { + input.ReleaseSuffix(); + initializeDispatchRelease.Set(); + } + + var messages = ReadLspMessages(output); + Assert.Contains(messages, entry => entry.Message["id"]?.GetValue() == 1); + var preInitialize = Assert.Single( + messages, + entry => entry.Message["id"]?.GetValue() == 2); + Assert.Equal( + LspServer.LspServerNotInitializedCode, + preInitialize.Message["error"]!["code"]!.GetValue()); + Assert.Equal(0, server.LiveDocumentBytesForTests); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public async Task RunAsync_PostInitializeTrafficUsesPublishedRunningState_Issue4849() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_initialize_publication"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot); + using var input = new StagedReadStream( + Encoding.UTF8.GetBytes( + Frame("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")), + Encoding.UTF8.GetBytes( + Frame("{\"jsonrpc\":\"2.0\",\"method\":\"initialized\",\"params\":{}}") + + Frame("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"unknown\"}"))); + using var output = new MarkerWriteGateMemoryStream("\"id\":1"); + + try + { + var runTask = server.RunAsync(input, output); + await output.WaitForMarkerAsync().WaitAsync(TestDeterminism.DefaultTimeout); + input.ReleaseSuffix(); + output.ReleaseMarker(); + + Assert.Equal( + CommandExitCodes.Success, + await runTask.WaitAsync(TestDeterminism.DefaultTimeout)); + } + finally + { + input.ReleaseSuffix(); + output.ReleaseMarker(); + } + + var messages = ReadLspMessages(output); + Assert.Contains(messages, entry => entry.Message["id"]?.GetValue() == 1); + var postInitialize = Assert.Single( + messages, + entry => entry.Message["id"]?.GetValue() == 2); + Assert.Equal(-32601, postInitialize.Message["error"]!["code"]!.GetValue()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_IdBearingCancellationUsesRequestLifecycleErrors_Issue4849() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_cancel_request_lifecycle"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot); + const string cancelBeforeInitialize = + "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"$/cancelRequest\",\"params\":{\"id\":1}}"; + using var beforeInput = new MemoryStream(Encoding.UTF8.GetBytes(Frame(cancelBeforeInitialize))); + using var beforeOutput = new MemoryStream(); + + Assert.Equal(CommandExitCodes.Success, server.Run(beforeInput, beforeOutput)); + var beforeResponse = Assert.Single(ReadLspMessages(beforeOutput)); + Assert.Equal( + LspServer.LspServerNotInitializedCode, + beforeResponse.Message["error"]!["code"]!.GetValue()); + + InitializeSession(server); + const string shutdown = "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"shutdown\"}"; + const string cancelAfterShutdown = + "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"$/cancelRequest\",\"params\":{\"id\":1}}"; + const string exit = "{\"jsonrpc\":\"2.0\",\"method\":\"exit\"}"; + using var afterInput = new MemoryStream(Encoding.UTF8.GetBytes( + Frame(shutdown) + Frame(cancelAfterShutdown) + Frame(exit))); + using var afterOutput = new MemoryStream(); + + Assert.Equal(CommandExitCodes.Success, server.Run(afterInput, afterOutput)); + var afterMessages = ReadLspMessages(afterOutput); + Assert.Contains(afterMessages, entry => entry.Message["id"]?.GetValue() == 8); + var afterResponse = Assert.Single( + afterMessages, + entry => entry.Message["id"]?.GetValue() == 9); + Assert.Equal(-32600, afterResponse.Message["error"]!["code"]!.GetValue()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public async Task RunAsync_QueuedShutdownRejectsLaterOverloadRequests_Issue4849() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_queued_shutdown"); + using var requestEntered = new ManualResetEventSlim(false); + using var shutdownReserved = new ManualResetEventSlim(false); + using var requestRelease = new ManualResetEventSlim(false); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer( + new DbReader(db), + "1.2.3", + ProgramRunner.CreateDefaultJsonOptions(), + projectRoot) + { + InboundSessionDispatchReservedForTesting = method => + { + if (string.Equals(method, "shutdown", StringComparison.Ordinal)) + shutdownReserved.Set(); + }, + BeforeSymbolRequestForTesting = cancellationToken => + { + requestEntered.Set(); + requestRelease.Wait(cancellationToken); + }, + }; + InitializeSession(server); + var frames = new StringBuilder(Frame( + "{\"jsonrpc\":\"2.0\",\"id\":\"active-4849\",\"method\":\"workspace/symbol\",\"params\":{\"query\":\"\"}}")); + for (var i = 0; i < 40; i++) + { + frames.Append(Frame(JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 484900 + i, + method = "unknown", + }))); + } + frames.Append(Frame("{\"jsonrpc\":\"2.0\",\"id\":484950,\"method\":\"shutdown\"}")); + frames.Append(Frame("{\"jsonrpc\":\"2.0\",\"id\":484951,\"method\":\"unknown\"}")); + frames.Append(Frame("{\"jsonrpc\":\"2.0\",\"method\":\"exit\"}")); + using var input = new MemoryStream(Encoding.UTF8.GetBytes(frames.ToString())); + using var output = new MemoryStream(); + + var runTask = server.RunAsync(input, output); + Assert.True(requestEntered.Wait(TestDeterminism.DefaultTimeout)); + Assert.True(shutdownReserved.Wait(TestDeterminism.DefaultTimeout)); + requestRelease.Set(); + + Assert.Equal( + CommandExitCodes.Success, + await runTask.WaitAsync(TestDeterminism.DefaultTimeout)); + var messages = ReadLspMessages(output); + var shutdown = Assert.Single( + messages, + entry => entry.Message["id"] is JsonValue responseId + && responseId.TryGetValue(out var value) + && value == 484950); + Assert.Null(shutdown.Message["result"]); + var afterShutdown = Assert.Single( + messages, + entry => entry.Message["id"] is JsonValue responseId + && responseId.TryGetValue(out var value) + && value == 484951); + Assert.Equal(-32600, afterShutdown.Message["error"]!["code"]!.GetValue()); + } + finally + { + requestRelease.Set(); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_LiveDocumentSync_UsesChangedBufferForPositionRequests_Issue3536() { @@ -351,9 +790,9 @@ public void HandleMessage_LiveDocumentSync_UsesChangedBufferForPositionRequests_ using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); - Assert.Null(server.HandleMessage(CreateDidOpenRequest(sourcePath, diskSource, version: 1))); - Assert.Null(server.HandleMessage(CreateDidChangeRequest(sourcePath, liveSource, version: 2))); - var liveResponse = server.HandleMessage(CreateDefinitionRequest( + Assert.Null(HandleInitializedMessage(server, CreateDidOpenRequest(sourcePath, diskSource, version: 1))); + Assert.Null(HandleInitializedMessage(server, CreateDidChangeRequest(sourcePath, liveSource, version: 2))); + var liveResponse = HandleInitializedMessage(server, CreateDefinitionRequest( sourcePath, 3536, 0, @@ -362,8 +801,8 @@ public void HandleMessage_LiveDocumentSync_UsesChangedBufferForPositionRequests_ Assert.NotNull(liveResponse); Assert.NotEmpty(liveResponse!["result"]!.AsArray()); - Assert.Null(server.HandleMessage(CreateDidCloseRequest(sourcePath))); - var closedResponse = server.HandleMessage(CreateDefinitionRequest( + Assert.Null(HandleInitializedMessage(server, CreateDidCloseRequest(sourcePath))); + var closedResponse = HandleInitializedMessage(server, CreateDefinitionRequest( sourcePath, 35361, 0, @@ -417,13 +856,13 @@ public void HandleMessage_LiveDocumentSync_EvictsOldestBufferWhenCacheIsFull_Iss for (var i = 0; i < sources.Count; i++) { var source = sources[i]; - Assert.Null(server.HandleMessage(CreateDidOpenRequest(source.Path, source.DiskSource, version: i + 1))); - Assert.Null(server.HandleMessage(CreateDidChangeRequest(source.Path, source.LiveSource, version: i + 100))); + Assert.Null(HandleInitializedMessage(server, CreateDidOpenRequest(source.Path, source.DiskSource, version: i + 1))); + Assert.Null(HandleInitializedMessage(server, CreateDidChangeRequest(source.Path, source.LiveSource, version: i + 100))); } Assert.NotNull(firstPath); Assert.NotNull(firstLiveSource); - var evictedResponse = server.HandleMessage(CreateDefinitionRequest( + var evictedResponse = HandleInitializedMessage(server, CreateDefinitionRequest( firstPath!, 35368, 0, @@ -434,7 +873,7 @@ public void HandleMessage_LiveDocumentSync_EvictsOldestBufferWhenCacheIsFull_Iss Assert.NotNull(lastPath); Assert.NotNull(lastLiveSource); - var retainedResponse = server.HandleMessage(CreateDefinitionRequest( + var retainedResponse = HandleInitializedMessage(server, CreateDefinitionRequest( lastPath!, 35369, 0, @@ -471,7 +910,7 @@ public void HandleMessage_UnsupportedOptionalMethods_ReturnMethodNotFound_Issues using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); var request = CreatePositionRequest(method, sourcePath, 4360, 0, 6); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Equal(-32601, response!["error"]!["code"]!.GetValue()); @@ -505,23 +944,23 @@ public class App using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); var countCallCharacter = CharacterOf(source, 3, "Count();"); - var hover = server.HandleMessage(CreatePositionRequest("textDocument/hover", sourcePath, 35362, 3, countCallCharacter)); + var hover = HandleInitializedMessage(server, CreatePositionRequest("textDocument/hover", sourcePath, 35362, 3, countCallCharacter)); Assert.NotNull(hover); Assert.Contains("Count", hover!["result"]!["contents"]!["value"]!.GetValue(), StringComparison.Ordinal); - var completion = server.HandleMessage(CreatePositionRequest("textDocument/completion", sourcePath, 35363, 3, countCallCharacter + 3)); + var completion = HandleInitializedMessage(server, CreatePositionRequest("textDocument/completion", sourcePath, 35363, 3, countCallCharacter + 3)); Assert.NotNull(completion); Assert.Contains(completion!["result"]!["items"]!.AsArray(), item => item!["label"]!.GetValue() == "Count"); - var highlights = server.HandleMessage(CreatePositionRequest("textDocument/documentHighlight", sourcePath, 35364, 3, countCallCharacter)); + var highlights = HandleInitializedMessage(server, CreatePositionRequest("textDocument/documentHighlight", sourcePath, 35364, 3, countCallCharacter)); Assert.NotNull(highlights); Assert.NotEmpty(highlights!["result"]!.AsArray()); - var semanticTokens = server.HandleMessage(CreateTextDocumentRequest("textDocument/semanticTokens/full", sourcePath, 35365)); + var semanticTokens = HandleInitializedMessage(server, CreateTextDocumentRequest("textDocument/semanticTokens/full", sourcePath, 35365)); Assert.NotNull(semanticTokens); Assert.NotEmpty(semanticTokens!["result"]!["data"]!.AsArray()); - var inlayHints = server.HandleMessage(CreateTextDocumentRequest("textDocument/inlayHint", sourcePath, 35367)); + var inlayHints = HandleInitializedMessage(server, CreateTextDocumentRequest("textDocument/inlayHint", sourcePath, 35367)); Assert.NotNull(inlayHints); Assert.True(inlayHints!["error"] is null, inlayHints["error"]?.ToJsonString()); Assert.Empty(inlayHints!["result"]!.AsArray()); @@ -560,7 +999,7 @@ public void HandleMessage_SemanticTokens_ClassifiesCSharpKeywordsModifiersAndDec using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); - var response = server.HandleMessage(CreateTextDocumentRequest("textDocument/semanticTokens/full", sourcePath, 4444)); + var response = HandleInitializedMessage(server, CreateTextDocumentRequest("textDocument/semanticTokens/full", sourcePath, 4444)); Assert.NotNull(response); var tokens = DecodeSemanticTokens(response!["result"]!["data"]!.AsArray(), source); @@ -628,7 +1067,7 @@ public void HandleMessage_InlayHint_HonorsRangeAndSuppressesExplicitTypes_Issue4 using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); - var response = server.HandleMessage(CreateInlayHintRequest(sourcePath, 4418, 1001, 0, 1002, 0)); + var response = HandleInitializedMessage(server, CreateInlayHintRequest(sourcePath, 4418, 1001, 0, 1002, 0)); Assert.NotNull(response); Assert.True(response!["error"] is null, response["error"]?.ToJsonString()); @@ -664,7 +1103,7 @@ public class App using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); var missingCharacter = CharacterOf(source, 3, "MissingPrefix();") + 3; - var completion = server.HandleMessage(CreatePositionRequest("textDocument/completion", sourcePath, 43601, 3, missingCharacter)); + var completion = HandleInitializedMessage(server, CreatePositionRequest("textDocument/completion", sourcePath, 43601, 3, missingCharacter)); Assert.NotNull(completion); Assert.False(completion!["result"]!["isIncomplete"]!.GetValue()); @@ -686,7 +1125,7 @@ public void HandleMessage_TooDeepJson_ReturnsParseError_Issue3021() using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); - var response = server.HandleMessage(BuildNestedLspRequest(LspServer.MaxJsonDepth + 1)); + var response = HandleInitializedMessage(server, BuildNestedLspRequest(LspServer.MaxJsonDepth + 1)); Assert.NotNull(response); Assert.Equal(-32700, response!["error"]!["code"]!.GetValue()); @@ -718,7 +1157,7 @@ public void HandleMessage_UnknownMethod_TruncatesMethodName_Issue3127() method, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var error = response!["error"]!; @@ -746,7 +1185,7 @@ public void HandleMessage_OverMaxPayload_ReturnsParseError_Issue3657() using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); var request = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + new string('m', LspServer.MaxLspFrameBytes) + "\"}"; - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Equal(-32700, response!["error"]!["code"]!.GetValue()); @@ -775,7 +1214,7 @@ public void HandleMessage_UnknownMethod_TruncatesMethodName_Issue3205() method, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var error = response!["error"]!; @@ -801,6 +1240,7 @@ public void HandleMessage_UnknownMethod_PreservesSlashDelimitedMethodName_Issue3 var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + InitializeSession(server); var request = JsonSerializer.Serialize(new { jsonrpc = "2.0", @@ -808,7 +1248,7 @@ public void HandleMessage_UnknownMethod_PreservesSlashDelimitedMethodName_Issue3 method = "textDocument/unknownHover", }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Equal(-32601, response!["error"]!["code"]!.GetValue()); @@ -830,7 +1270,7 @@ public void HandleMessage_ObjectRequestId_ReturnsInvalidRequest_Issue3204() using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); - var response = server.HandleMessage("""{"jsonrpc":"2.0","id":{"nested":1},"method":"initialize"}"""); + var response = HandleInitializedMessage(server, """{"jsonrpc":"2.0","id":{"nested":1},"method":"initialize"}"""); Assert.NotNull(response); Assert.Equal(-32600, response!["error"]!["code"]!.GetValue()); @@ -859,7 +1299,7 @@ public void HandleMessage_OversizedStringRequestId_ReturnsInvalidRequest_Issue32 method = "initialize", }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Equal(-32600, response!["error"]!["code"]!.GetValue()); @@ -881,6 +1321,7 @@ public void HandleMessage_InvalidParams_ReturnsStableErrorMessage_Issue3200() var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + InitializeSession(server); var request = JsonSerializer.Serialize(new { jsonrpc = "2.0", @@ -892,7 +1333,7 @@ public void HandleMessage_InvalidParams_ReturnsStableErrorMessage_Issue3200() }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Equal(-32602, response!["error"]!["code"]!.GetValue()); @@ -927,7 +1368,7 @@ public void HandleMessage_InternalFailure_ReturnsStableErrorMessage_Issue3200() }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Equal(-32603, response!["error"]!["code"]!.GetValue()); @@ -958,7 +1399,7 @@ public void HandleMessage_OwnedQuerySnapshotRefreshesAfterExternalWalCommit_Issu @params = new { query = "AddedAfterLspStart" }, }); - var before = server.HandleMessage(request); + var before = HandleInitializedMessage(server, request); Assert.NotNull(before); Assert.Empty(before!["result"]!.AsArray()); @@ -988,7 +1429,7 @@ public void HandleMessage_OwnedQuerySnapshotRefreshesAfterExternalWalCommit_Issu ]); var expectedArtifacts = CaptureDatabaseArtifactsForLsp(dbPath); - var after = server.HandleMessage(request); + var after = HandleInitializedMessage(server, request); Assert.NotNull(after); var symbol = Assert.Single(after!["result"]!.AsArray()); @@ -1044,7 +1485,7 @@ public void HandleMessage_WorkspaceSymbol_RejectsOversizedQuery_Issue3128() }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var error = response!["error"]!; @@ -1088,7 +1529,7 @@ public void HandleMessage_WorkspaceSymbol_HonorsClientLimit_Issue3537() }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Equal(2, response!["result"]!.AsArray().Count); @@ -1116,6 +1557,7 @@ public void Run_DocumentSymbol_StreamsBoundedPartialResultsAndWorkDoneProgress_I using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + InitializeSession(server); var request = JsonSerializer.Serialize(new { jsonrpc = "2.0", @@ -1192,6 +1634,7 @@ public async Task RunAsync_DocumentSymbol_WritesWorkDoneBeginBeforeSymbolWorkCom release.Wait(cancellationToken); }, }; + InitializeSession(server); var request = JsonSerializer.Serialize(new { jsonrpc = "2.0", @@ -1247,6 +1690,7 @@ public void Run_WorkspaceSymbol_SurfacesPartialResultTruncation_Issue4721() using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + InitializeSession(server); var request = JsonSerializer.Serialize(new { jsonrpc = "2.0", @@ -1301,6 +1745,7 @@ public void Run_DocumentSymbol_CancelRequestEndsProgressAndReturnsCancellationEr TestProjectHelper.InsertIndexedFile(dbPath, "cancel.cs", "csharp", source); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + InitializeSession(server); var request = JsonSerializer.Serialize(new { jsonrpc = "2.0", @@ -1361,6 +1806,7 @@ public void Run_CancelRequestBypassesFullInboundQueue_Issue4721() cancellationToken.ThrowIfCancellationRequested(); }, }; + InitializeSession(server); var frames = new StringBuilder(); frames.Append(Frame( """ @@ -1372,8 +1818,7 @@ public void Run_CancelRequestBypassesFullInboundQueue_Issue4721() { jsonrpc = "2.0", id = 5000 + i, - method = "initialize", - @params = new { }, + method = "unknown", }))); } frames.Append(Frame( @@ -1423,6 +1868,7 @@ public async Task RunAsync_QueuePressurePreservesDocumentSyncNotifications_Issue release.Wait(cancellationToken); }, }; + InitializeSession(server); var frames = new StringBuilder(); frames.Append(Frame( """ @@ -1434,8 +1880,7 @@ public async Task RunAsync_QueuePressurePreservesDocumentSyncNotifications_Issue { jsonrpc = "2.0", id = 6000 + i, - method = "initialize", - @params = new { }, + method = "unknown", }))); } frames.Append(Frame(JsonSerializer.Serialize(new @@ -1507,6 +1952,7 @@ public async Task RunAsync_ServerBusyBackpressureRetainsEveryRejectedResponse_Is requestRelease.Wait(cancellationToken); }, }; + InitializeSession(server); var frames = new StringBuilder(); frames.Append(Frame( """ @@ -1518,8 +1964,7 @@ public async Task RunAsync_ServerBusyBackpressureRetainsEveryRejectedResponse_Is { jsonrpc = "2.0", id = 7000 + i, - method = "initialize", - @params = new { }, + method = "unknown", }))); } const string cancel = @@ -1587,6 +2032,7 @@ public async Task RunAsync_CancelledPartialResultsReportAlreadyEmittedCount_Issu cancellationToken.Register(() => cancellationObserved.TrySetResult()); }, }; + InitializeSession(server); var request = JsonSerializer.Serialize(new { jsonrpc = "2.0", @@ -1689,7 +2135,7 @@ public void HandleMessage_SymbolProgressTokensRejectUnboundedOrStructuredValues_ foreach (var request in requests) { - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Equal(-32602, response!["error"]!["code"]!.GetValue()); Assert.Equal("Invalid params", response["error"]!["message"]!.GetValue()); @@ -1738,7 +2184,7 @@ public void Run_MalformedJsonFrame_WritesParseErrorAndContinues() } [Fact] - public void Run_ShutdownThenExit_StopsBeforeLaterFrames() + public async Task RunAsync_ShutdownThenExit_StopsBeforeLaterFrames_Issue4849() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_shutdown_exit"); try @@ -1748,19 +2194,40 @@ public void Run_ShutdownThenExit_StopsBeforeLaterFrames() 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(); + const string initializeRequest = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"; + const string initializedNotification = "{\"jsonrpc\":\"2.0\",\"method\":\"initialized\",\"params\":{}}"; + const string postShutdownNotification = "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didClose\",\"params\":{}}"; + const string postShutdownRequest = "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"workspace/symbol\",\"params\":{\"query\":\"Needle\"}}"; + const string lateInitializeRequest = "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"initialize\",\"params\":{}}"; + using var input = new StagedReadStream( + Encoding.UTF8.GetBytes(Frame(initializeRequest)), + Encoding.UTF8.GetBytes( + Frame(initializedNotification) + + Frame(shutdownRequest) + + Frame(postShutdownNotification) + + Frame(postShutdownRequest) + + Frame(exitNotification) + + Frame(lateInitializeRequest))); + using var output = new SignalingMemoryStream(); - var exitCode = server.Run(input, output); + var runTask = server.RunAsync(input, output); + await output.WaitForWriteAsync().WaitAsync(TestDeterminism.DefaultTimeout); + input.ReleaseSuffix(); + var exitCode = await runTask.WaitAsync(TestDeterminism.DefaultTimeout); Assert.Equal(CommandExitCodes.Success, exitCode); output.Position = 0; + Assert.True(LspServer.TryReadMessage(output, out var initializePayload)); + using var initialize = JsonDocument.Parse(initializePayload); + Assert.Equal(1, initialize.RootElement.GetProperty("id").GetInt32()); 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.True(LspServer.TryReadMessage(output, out var postShutdownPayload)); + using var postShutdown = JsonDocument.Parse(postShutdownPayload); + Assert.Equal(3, postShutdown.RootElement.GetProperty("id").GetInt32()); + Assert.Equal(-32600, postShutdown.RootElement.GetProperty("error").GetProperty("code").GetInt32()); Assert.False(LspServer.TryReadMessage(output, out _)); } finally @@ -1818,7 +2285,7 @@ public void HandleMessage_DocumentSymbol_ReturnsIndexedSymbols() }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var symbols = response!["result"]!.AsArray(); @@ -1856,7 +2323,7 @@ public void HandleMessage_DocumentSymbol_DoesNotNestSameRangeTopLevelSymbols_Iss }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var symbols = response!["result"]!.AsArray(); @@ -1895,7 +2362,7 @@ public void HandleMessage_DocumentSymbol_NestsSameRangeChildAfterContainer_Issue }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var symbols = response!["result"]!.AsArray(); @@ -1933,7 +2400,7 @@ public void HandleMessage_DocumentSymbol_DisambiguatesSameLineRecordContainersBy }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var roots = response!["result"]!.AsArray(); @@ -1977,7 +2444,7 @@ public void HandleMessage_DocumentSymbol_NestsMixedRecordMembersDeterministicall }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var roots = response!["result"]!.AsArray(); @@ -2019,7 +2486,7 @@ public void HandleMessage_DocumentSymbol_NestsSameStartLongerContainerBeforeChil }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var symbols = response!["result"]!.AsArray(); @@ -2061,7 +2528,7 @@ public void HandleMessage_DocumentSymbol_ResolvesDuplicateBasenamesByRelativePat }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var names = response!["result"]! @@ -2103,7 +2570,7 @@ public void HandleMessage_DocumentSymbol_DoesNotSuffixMatchProjectRootedUnindexe }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Empty(response!["result"]!.AsArray()); @@ -2135,7 +2602,7 @@ public void HandleMessage_DocumentSymbol_RejectsOversizedTextDocumentUri_Issue31 }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var error = response!["error"]!; @@ -2188,7 +2655,7 @@ public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue31 }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var symbols = response!["result"]!.AsArray(); @@ -2246,7 +2713,7 @@ public void HandleMessage_DocumentSymbol_CapsMaterializationBeforeSorting_Issue3 }); using var activity = new Activity("lsp-document-symbol-test").Start(); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var roots = response!["result"]!.AsArray(); @@ -2287,7 +2754,7 @@ public void HandleMessage_DocumentSymbol_RejectsNonStringTextDocumentUri_Issue32 }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var error = response!["error"]!; @@ -2324,7 +2791,7 @@ public void HandleMessage_DocumentSymbol_RejectsNonFileTextDocumentUri_Issue3206 }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var error = response!["error"]!; @@ -2363,7 +2830,7 @@ public void HandleMessage_Definition_ReturnsLocationForTokenAtPosition() }, }); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var locations = response!["result"]!.AsArray(); @@ -2401,9 +2868,9 @@ public Widget() { } using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); var classCharacter = CharacterOf(source, 0, "Widget"); - var definition = server.HandleMessage(CreateDefinitionRequest(sourcePath, 44431, 0, classCharacter)); - var hover = server.HandleMessage(CreatePositionRequest("textDocument/hover", sourcePath, 44432, 0, classCharacter)); - var references = server.HandleMessage(CreateReferencesRequest(sourcePath, 44433, 0, classCharacter, includeDeclaration: true)); + var definition = HandleInitializedMessage(server, CreateDefinitionRequest(sourcePath, 44431, 0, classCharacter)); + var hover = HandleInitializedMessage(server, CreatePositionRequest("textDocument/hover", sourcePath, 44432, 0, classCharacter)); + var references = HandleInitializedMessage(server, CreateReferencesRequest(sourcePath, 44433, 0, classCharacter, includeDeclaration: true)); Assert.NotNull(definition); var definitionLocation = Assert.Single(definition!["result"]!.AsArray()); @@ -2445,7 +2912,7 @@ public void HandleMessage_Declaration_ReturnsDefinitionLocation_Issues3537And442 0, source.IndexOf("Needle();", StringComparison.Ordinal)); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var locations = response!["result"]!.AsArray(); @@ -2480,7 +2947,9 @@ public void HandleMessage_Definition_UsesTrackedWorkspaceFolders_Issue3537() var beforeInitialize = server.HandleMessage(request); Assert.NotNull(beforeInitialize); - Assert.Empty(beforeInitialize!["result"]!.AsArray()); + Assert.Equal( + LspServer.LspServerNotInitializedCode, + beforeInitialize!["error"]!["code"]!.GetValue()); var initialize = JsonSerializer.Serialize(new { @@ -2495,9 +2964,9 @@ public void HandleMessage_Definition_UsesTrackedWorkspaceFolders_Issue3537() }, }, }); - Assert.NotNull(server.HandleMessage(initialize)); + Assert.NotNull(InitializeSession(server, initialize)); - var afterInitialize = server.HandleMessage(request); + var afterInitialize = HandleInitializedMessage(server, request); Assert.NotNull(afterInitialize); var locations = afterInitialize!["result"]!.AsArray(); var location = Assert.Single(locations); @@ -2519,9 +2988,9 @@ public void HandleMessage_Definition_UsesTrackedWorkspaceFolders_Issue3537() }, }, }); - Assert.Null(server.HandleMessage(removeFolder)); + Assert.Null(HandleInitializedMessage(server, removeFolder)); - var afterRemove = server.HandleMessage(request); + var afterRemove = HandleInitializedMessage(server, request); Assert.NotNull(afterRemove); Assert.Empty(afterRemove!["result"]!.AsArray()); } @@ -2547,9 +3016,9 @@ public void HandleMessage_Definition_DoesNotMapRelativeIndexPathToAddedWorkspace TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", primarySource); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); - Assert.NotNull(server.HandleMessage(CreateInitializeRequestWithWorkspaceFolder(secondaryRoot, 35372))); + Assert.NotNull(HandleInitializedMessage(server, CreateInitializeRequestWithWorkspaceFolder(secondaryRoot, 35372))); - var response = server.HandleMessage(CreateDefinitionRequest( + var response = HandleInitializedMessage(server, CreateDefinitionRequest( secondaryPath, 35373, 0, @@ -2583,9 +3052,9 @@ public void HandleMessage_Definition_KeepsRelativeResultUriAtProjectRoot_Issue35 TestProjectHelper.InsertIndexedFile(dbPath, callerPath, "csharp", callerSource); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); - Assert.NotNull(server.HandleMessage(CreateInitializeRequestWithWorkspaceFolder(secondaryRoot, 35376))); + Assert.NotNull(InitializeSession(server, CreateInitializeRequestWithWorkspaceFolder(secondaryRoot, 35376))); - var response = server.HandleMessage(CreateDefinitionRequest( + var response = HandleInitializedMessage(server, CreateDefinitionRequest( callerPath, 35377, 0, @@ -2634,7 +3103,7 @@ void Run() { } using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); var request = CreateDefinitionRequest(betaPath, 31, 3, CharacterOf(betaSource, 3, "Run();")); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var locations = response!["result"]!.AsArray(); @@ -2670,7 +3139,7 @@ public void HandleMessage_Definition_ReturnsMultipleWorkspaceCandidates_Issue353 using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); var request = CreateDefinitionRequest(callerPath, 3537, 0, callerSource.IndexOf("Shared();", StringComparison.Ordinal)); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var uris = response!["result"]! @@ -2720,7 +3189,7 @@ class Beta using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); var request = CreateReferencesRequest(betaPath, 32, 4, CharacterOf(betaSource, 4, "Worker();")); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var locations = response!["result"]!.AsArray(); @@ -2757,8 +3226,8 @@ void Needle() { } var withoutDeclaration = CreateReferencesRequest(sourcePath, 3537, 3, character, includeDeclaration: false); var withDeclaration = CreateReferencesRequest(sourcePath, 3538, 3, character, includeDeclaration: true); - var withoutResponse = server.HandleMessage(withoutDeclaration); - var withResponse = server.HandleMessage(withDeclaration); + var withoutResponse = HandleInitializedMessage(server, withoutDeclaration); + var withResponse = HandleInitializedMessage(server, withDeclaration); Assert.NotNull(withoutResponse); Assert.NotNull(withResponse); @@ -2847,20 +3316,20 @@ class Resolver Assert.Equal(CharacterOf(twoArgCallerSource, 0, "Choose") + 1, twoArgumentReference.Column); Assert.Equal(requestCharacter + 1, sameFileReference.Column); - var withoutDeclaration = server.HandleMessage(CreateReferencesRequest( + var withoutDeclaration = HandleInitializedMessage(server, CreateReferencesRequest( definitionPath, 46223, 4, requestCharacter, includeDeclaration: false)); - var withDeclaration = server.HandleMessage(CreateReferencesRequest( + var withDeclaration = HandleInitializedMessage(server, CreateReferencesRequest( definitionPath, 46224, 4, requestCharacter, includeDeclaration: true)); var lastCappedCallLine = 8 + cappedCallCount - 1; - var cappedDefinition = server.HandleMessage(CreateDefinitionRequest( + var cappedDefinition = HandleInitializedMessage(server, CreateDefinitionRequest( cappedDefinitionPath, 46225, lastCappedCallLine, @@ -2935,7 +3404,7 @@ class Beta using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); var request = CreateReferencesRequest(betaPath, 33, 2, CharacterOf(betaSource, 2, "WriteLine")); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); var locations = response!["result"]!.AsArray(); @@ -2964,13 +3433,14 @@ public void HandleMessage_Definition_ReturnsEmptyForUnindexedDocument() File.WriteAllText(unindexedPath, unindexedSource); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + InitializeSession(server); var request = CreateDefinitionRequest( unindexedPath, 4, 0, unindexedSource.IndexOf("Needle();", StringComparison.Ordinal)); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Empty(response!["result"]!.AsArray()); @@ -2997,6 +3467,7 @@ public void HandleMessage_Definition_UnindexedDocument_EmitsLookupFailureTrace_I File.WriteAllText(unindexedPath, unindexedSource); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + InitializeSession(server); var request = CreateDefinitionRequest( unindexedPath, 3428, @@ -3007,7 +3478,7 @@ public void HandleMessage_Definition_UnindexedDocument_EmitsLookupFailureTrace_I var expectedTraceId = parentActivity.TraceId; using var listener = CaptureCodeIndexActivities(activities); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Empty(response!["result"]!.AsArray()); @@ -3047,7 +3518,7 @@ public void HandleMessage_Definition_ReturnsEmptyForOutsideProjectDocument() 0, outsideSource.IndexOf("Needle();", StringComparison.Ordinal)); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Empty(response!["result"]!.AsArray()); @@ -3079,7 +3550,7 @@ public void HandleMessage_Definition_ReturnsEmptyForOversizedIndexedDocument() 0, oversizedSource.IndexOf("Needle();", StringComparison.Ordinal)); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Empty(response!["result"]!.AsArray()); @@ -3110,7 +3581,7 @@ public void HandleMessage_Definition_ReturnsEmptyForLineOverPositionBudget_Issue 0, oversizedLine.IndexOf("Needle();", StringComparison.Ordinal)); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Empty(response!["result"]!.AsArray()); @@ -3145,7 +3616,7 @@ public void HandleMessage_Definition_HonorsCaseInsensitiveWorkspaceCasing() 0, source.IndexOf("Needle();", StringComparison.Ordinal)); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.NotEmpty(response!["result"]!.AsArray()); @@ -3181,7 +3652,7 @@ public void HandleMessage_Definition_RejectsCaseVariantWhenWorkspaceCaseSensitiv 0, source.IndexOf("Needle();", StringComparison.Ordinal)); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Empty(response!["result"]!.AsArray()); @@ -3223,7 +3694,7 @@ public void HandleMessage_Definition_ResolvesIndexedDocumentBeyondBasenameCandid 0, source.IndexOf("Needle();", StringComparison.Ordinal)); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.NotEmpty(response!["result"]!.AsArray()); @@ -3264,7 +3735,7 @@ public void HandleMessage_Definition_BasenameFallbackHonorsCandidateCap_Issue313 0, source.IndexOf("Needle();", StringComparison.Ordinal)); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Empty(response!["result"]!.AsArray()); @@ -3289,6 +3760,7 @@ public void HandleMessage_Definition_RootlessRejectsRelativeIndexedPathWithoutWo TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", source); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions()); + InitializeSession(server); var request = CreateDefinitionRequest( sourcePath, 3426, @@ -3299,7 +3771,7 @@ public void HandleMessage_Definition_RootlessRejectsRelativeIndexedPathWithoutWo var expectedTraceId = parentActivity.TraceId; using var listener = CaptureCodeIndexActivities(activities); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.Empty(response!["result"]!.AsArray()); @@ -3329,14 +3801,14 @@ public void HandleMessage_Definition_RootlessUsesWorkspaceFolderForRelativeIndex TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", source); using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions()); - Assert.NotNull(server.HandleMessage(CreateInitializeRequestWithWorkspaceFolder(projectRoot, 34260))); + Assert.NotNull(InitializeSession(server, CreateInitializeRequestWithWorkspaceFolder(projectRoot, 34260))); var request = CreateDefinitionRequest( sourcePath, 34261, 0, source.IndexOf("Needle();", StringComparison.Ordinal)); - var response = server.HandleMessage(request); + var response = HandleInitializedMessage(server, request); Assert.NotNull(response); Assert.NotEmpty(response!["result"]!.AsArray()); @@ -3509,6 +3981,32 @@ private static int CharacterOf(string source, int line, string value) return lines[line].IndexOf(value, StringComparison.Ordinal); } + private static JsonObject? HandleInitializedMessage(LspServer server, string payload) + { + lock (server) + { + if (!InitializedServers.TryGetValue(server, out _)) + InitializeSession(server); + } + + return server.HandleMessage(payload); + } + + private static JsonObject InitializeSession( + LspServer server, + string payload = """{"jsonrpc":"2.0","id":"__test_initialize__","method":"initialize","params":{}}""") + { + lock (server) + { + var initialize = server.HandleMessage(payload); + Assert.NotNull(initialize); + Assert.Null(initialize!["error"]); + if (!InitializedServers.TryGetValue(server, out _)) + InitializedServers.Add(server, new object()); + return initialize; + } + } + private static List<(int Line, int Character, string Text, int Type, int Modifiers)> DecodeSemanticTokens( JsonArray data, string source) @@ -3621,15 +4119,15 @@ private static void AssertSourceSemanticsIssue4622( string sourcePath, IReadOnlyList sourceLines) { - var documentSymbols = server.HandleMessage(CreateTextDocumentRequest("textDocument/documentSymbol", sourcePath, 46220)); - var workspaceSymbols = server.HandleMessage(JsonSerializer.Serialize(new + var documentSymbols = HandleInitializedMessage(server, CreateTextDocumentRequest("textDocument/documentSymbol", sourcePath, 46220)); + var workspaceSymbols = HandleInitializedMessage(server, JsonSerializer.Serialize(new { jsonrpc = "2.0", id = 46221, method = "workspace/symbol", @params = new { query = "Widget" }, })); - var inlayHints = server.HandleMessage(CreateInlayHintRequest(sourcePath, 46222, 0, 0, 4, int.MaxValue)); + var inlayHints = HandleInitializedMessage(server, CreateInlayHintRequest(sourcePath, 46222, 0, 0, 4, int.MaxValue)); Assert.NotNull(documentSymbols); Assert.NotNull(workspaceSymbols);