From 88b624f428fc8f032177ae17e770c8dcb0a4c95c Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Wed, 2 Sep 2026 13:37:31 +0200 Subject: [PATCH] Await the language server's own async calls instead of blocking on them The server handled a didOpen/didChange notification from an async method that then blocked on the workspace's text lookup with .Result, and sent its state notification by blocking on the write lock from an event handler. A console server has no synchronization context, so neither deadlocked, but each parked a thread-pool thread while another response held the lock. The overlay is now awaited with the notification's token. The state report stays fire-and-forget because an Action handler cannot await; the write semaphore queues waiters in order, so states still arrive in the order they were reached, and a failure goes to the log instead of vanishing. The GeneratedSources tests blocked the same way for no reason NUnit needs. Assisted-by: Claude:claude-fable-5-1:Claude Code --- src/Stampeded.RoslynLsp/RoslynLspServer.cs | 18 ++++++++++++------ .../GeneratedSourcesTests.cs | 16 ++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/Stampeded.RoslynLsp/RoslynLspServer.cs b/src/Stampeded.RoslynLsp/RoslynLspServer.cs index 80761aa..d9d971d 100644 --- a/src/Stampeded.RoslynLsp/RoslynLspServer.cs +++ b/src/Stampeded.RoslynLsp/RoslynLspServer.cs @@ -107,7 +107,7 @@ async Task HandleNotificationAsync(string method, JsonElement parameters, Cancel case "textDocument/didChange": // The client sends what the review is showing, which may be a revision that // is not on disk. An overlay is how the workspace is told. - Overlay(parameters); + await OverlayAsync(parameters, ct); break; case "initialized": case "$/cancelRequest": @@ -206,8 +206,14 @@ void WatchParent(JsonElement parameters) }); } + // An event handler cannot await, and the client only needs the notification to arrive + // eventually: the write lock queues waiters in order, so states are still sent in the + // order they were reached. void ReportState() - => Notify("stampeded/state", new { state = head.State.ToString(), detail = head.StateDetail }); + => NotifyAsync("stampeded/state", new { state = head.State.ToString(), detail = head.StateDetail }) + .ContinueWith( + t => CliLog.Write("roslyn-lsp", $"state notification failed: {t.Exception?.GetBaseException().Message}"), + TaskContinuationOptions.OnlyOnFaulted); /// Derives the base-side workspace from the head one, given the texts of the /// revision being compared against. @@ -242,7 +248,7 @@ static Dictionary ReadTexts(JsonElement parameters, string name) return texts; } - void Overlay(JsonElement parameters) + async Task OverlayAsync(JsonElement parameters, CancellationToken ct) { if (!parameters.TryGetProperty("textDocument", out var document) || !document.TryGetProperty("uri", out var uri)) @@ -261,7 +267,7 @@ void Overlay(JsonElement parameters) // Only when it differs from what the workspace has: the client opens a document to // be able to ask about it at all, and re-stating the file on disk would throw away // the compilation that already knows it. - if (target.Service.GetDocumentTextAsync(target.RelPath, CancellationToken.None).Result is { } current + if (await target.Service.GetDocumentTextAsync(target.RelPath, ct) is { } current && string.Equals(current.ReplaceLineEndings("\n"), text.ReplaceLineEndings("\n"), StringComparison.Ordinal)) { return; @@ -619,11 +625,11 @@ async Task RespondAsync(JsonElement id, object? result) await SendAsync(payload); } - void Notify(string method, object parameters) + Task NotifyAsync(string method, object parameters) { var payload = JsonSerializer.SerializeToUtf8Bytes( new { jsonrpc = "2.0", method, @params = parameters }, Json); - SendAsync(payload).GetAwaiter().GetResult(); + return SendAsync(payload); } async Task SendAsync(byte[] payload) diff --git a/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs b/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs index b7eb357..080e3e8 100644 --- a/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs +++ b/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs @@ -54,13 +54,13 @@ public void KeepsOutputOfTheSameGeneratorInDifferentProjectsApart() } [Test] - public void PairsTheTwoSidesEvenWhenTheyWereBuiltDifferently() + public async Task PairsTheTwoSidesEvenWhenTheyWereBuiltDifferently() { string baseTree = NewDirectory(), headTree = NewDirectory(); Write(baseTree, "src/Lib/obj/Debug/net10.0/generated/G/E/Thing.g.cs", "one\ntwo\n"); Write(headTree, "src/Lib/obj/Release/net11.0/generated/G/E/Thing.g.cs", "one\ntwo changed\n"); - var files = GeneratedSources.DiffAsync(baseTree, headTree).GetAwaiter().GetResult(); + var files = await GeneratedSources.DiffAsync(baseTree, headTree); Assert.That(files, Has.Count.EqualTo(1)); Assert.That(files[0].Kind, Is.EqualTo(FileChangeKind.Modified)); @@ -69,13 +69,13 @@ public void PairsTheTwoSidesEvenWhenTheyWereBuiltDifferently() } [Test] - public void ReportsWhatOnlyOneSideGenerated() + public async Task ReportsWhatOnlyOneSideGenerated() { string baseTree = NewDirectory(), headTree = NewDirectory(); Write(baseTree, "src/Lib/obj/Debug/net10.0/generated/G/E/Gone.g.cs", "was here\n"); Write(headTree, "src/Lib/obj/Debug/net10.0/generated/G/E/New.g.cs", "is here\n"); - var files = GeneratedSources.DiffAsync(baseTree, headTree).GetAwaiter().GetResult(); + var files = await GeneratedSources.DiffAsync(baseTree, headTree); Assert.That(files.Select(f => (f.Path, f.Kind)), Is.EquivalentTo(new[] { ("src/Lib/generated/G/E/Gone.g.cs", FileChangeKind.Deleted), @@ -84,24 +84,24 @@ public void ReportsWhatOnlyOneSideGenerated() } [Test] - public void LeavesOutGeneratedFilesTheChangeDidNotMove() + public async Task LeavesOutGeneratedFilesTheChangeDidNotMove() { string baseTree = NewDirectory(), headTree = NewDirectory(); Write(baseTree, "src/Lib/obj/Debug/net10.0/generated/G/E/Same.g.cs", "unchanged\n"); Write(headTree, "src/Lib/obj/Debug/net10.0/generated/G/E/Same.g.cs", "unchanged\n"); - Assert.That(GeneratedSources.DiffAsync(baseTree, headTree).GetAwaiter().GetResult(), Is.Empty, + Assert.That(await GeneratedSources.DiffAsync(baseTree, headTree), Is.Empty, "a generator whose output stands still is not part of the change"); } [Test] - public void CarriesWhereEachSideCanBeReadFrom() + public async Task CarriesWhereEachSideCanBeReadFrom() { string baseTree = NewDirectory(), headTree = NewDirectory(); Write(baseTree, "src/Lib/obj/Debug/net10.0/generated/G/E/Thing.g.cs", "before\n"); Write(headTree, "src/Lib/obj/Debug/net10.0/generated/G/E/Thing.g.cs", "after\n"); - var file = GeneratedSources.DiffAsync(baseTree, headTree).GetAwaiter().GetResult().Single(); + var file = (await GeneratedSources.DiffAsync(baseTree, headTree)).Single(); // Nothing can read these out of a commit, so the diff has to say where they are. Assert.That(file.IsGenerated, Is.True);