From cf699b244213212e0a9d9195fe59511100ed4d5b Mon Sep 17 00:00:00 2001 From: David Kaya Date: Mon, 23 Mar 2026 23:16:48 +0100 Subject: [PATCH] fix: stabilize TypeScript LSP startup Seed new TypeScript LSP profiles with --stdio, validate that shared profiles keep the required flag, auto-add it for existing profiles in the sidecar runtime, and include recent stderr when a language server dies before completing a request. Add shared and sidecar regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Eryx.AgentHost/Services/LspToolSession.cs | 50 +++++++++++++++++-- .../LspToolSessionTests.cs | 29 +++++++++++ src/renderer/App.tsx | 2 +- src/shared/domain/tooling.ts | 17 +++++++ tests/shared/tooling.test.ts | 16 ++++++ 5 files changed, 110 insertions(+), 4 deletions(-) diff --git a/sidecar/src/Eryx.AgentHost/Services/LspToolSession.cs b/sidecar/src/Eryx.AgentHost/Services/LspToolSession.cs index db0640a..1c43f7b 100644 --- a/sidecar/src/Eryx.AgentHost/Services/LspToolSession.cs +++ b/sidecar/src/Eryx.AgentHost/Services/LspToolSession.cs @@ -25,6 +25,7 @@ internal sealed class LspToolSession : IAsyncDisposable private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _documentLock = new(1, 1); private readonly HashSet _openedDocumentUris = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentQueue _stderrLines = new(); private readonly Task _stdoutReaderTask; private readonly Task _stderrReaderTask; private int _nextRequestId; @@ -97,7 +98,7 @@ internal sealed class LspToolSession : IAsyncDisposable CreateNoWindow = true, }; - foreach (string arg in profile.Args) + foreach (string arg in ResolveProcessArguments(profile)) { startInfo.ArgumentList.Add(arg); } @@ -125,6 +126,19 @@ internal sealed class LspToolSession : IAsyncDisposable return session; } + internal static IReadOnlyList ResolveProcessArguments(RunTurnLspProfileConfigDto profile) + { + List args = profile.Args.ToList(); + + if (UsesTypeScriptLanguageServer(profile.Command) + && !args.Any(arg => string.Equals(arg, "--stdio", StringComparison.OrdinalIgnoreCase))) + { + args.Add("--stdio"); + } + + return args; + } + public async ValueTask DisposeAsync() { _cts.Cancel(); @@ -483,8 +497,7 @@ internal sealed class LspToolSession : IAsyncDisposable } finally { - FailPendingRequests(new InvalidOperationException( - $"LSP profile \"{_profile.Name}\" stopped before a pending request completed.")); + FailPendingRequests(CreatePendingRequestInterruptedException()); } } @@ -502,6 +515,7 @@ internal sealed class LspToolSession : IAsyncDisposable if (!string.IsNullOrWhiteSpace(line)) { + RecordStderrLine(line); Console.Error.WriteLine($"[eryx lsp:{_profile.Id}] {line}"); } } @@ -602,6 +616,28 @@ internal sealed class LspToolSession : IAsyncDisposable _pending.Clear(); } + private void RecordStderrLine(string line) + { + _stderrLines.Enqueue(line); + while (_stderrLines.Count > 8 && _stderrLines.TryDequeue(out _)) + { + } + } + + private InvalidOperationException CreatePendingRequestInterruptedException() + { + string message = $"LSP profile \"{_profile.Name}\" stopped before a pending request completed."; + string[] stderrLines = _stderrLines.ToArray(); + + if (stderrLines.Length == 0) + { + return new InvalidOperationException(message); + } + + string detail = string.Join(" ", stderrLines.TakeLast(3)); + return new InvalidOperationException($"{message} Last stderr: {detail}"); + } + private string ResolveProjectPath(string relativePath) { if (string.IsNullOrWhiteSpace(relativePath)) @@ -667,6 +703,14 @@ internal sealed class LspToolSession : IAsyncDisposable return Math.Max(value - 1, 0); } + private static bool UsesTypeScriptLanguageServer(string command) + { + string executableName = Path.GetFileName(command.Trim()).ToLowerInvariant(); + return executableName is "typescript-language-server" + or "typescript-language-server.cmd" + or "typescript-language-server.exe"; + } + private static string ToFileUri(string path) { return new Uri(path).AbsoluteUri; diff --git a/sidecar/tests/Eryx.AgentHost.Tests/LspToolSessionTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/LspToolSessionTests.cs index 1572086..a452595 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/LspToolSessionTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/LspToolSessionTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Eryx.AgentHost.Contracts; using Eryx.AgentHost.Services; namespace Eryx.AgentHost.Tests; @@ -24,4 +25,32 @@ public sealed class LspToolSessionTests Assert.Contains("relativePath", json); } + + [Fact] + public void ResolveProcessArguments_AddsStdioForTypeScriptLanguageServer() + { + RunTurnLspProfileConfigDto profile = new() + { + Command = "typescript-language-server", + Args = [], + }; + + IReadOnlyList args = LspToolSession.ResolveProcessArguments(profile); + + Assert.Equal(["--stdio"], args); + } + + [Fact] + public void ResolveProcessArguments_DoesNotDuplicateStdioWhenAlreadyPresent() + { + RunTurnLspProfileConfigDto profile = new() + { + Command = "typescript-language-server", + Args = ["--stdio"], + }; + + IReadOnlyList args = LspToolSession.ResolveProcessArguments(profile); + + Assert.Equal(["--stdio"], args); + } } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5556275..5a6a38d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -72,7 +72,7 @@ function createDraftLspProfile(): LspProfileDefinition { id: createId('lsp'), name: 'New LSP Profile', command: '', - args: [], + args: ['--stdio'], languageId: 'typescript', fileExtensions: ['.ts', '.tsx'], createdAt: timestamp, diff --git a/src/shared/domain/tooling.ts b/src/shared/domain/tooling.ts index 2a9bb45..5e2f526 100644 --- a/src/shared/domain/tooling.ts +++ b/src/shared/domain/tooling.ts @@ -126,6 +126,11 @@ export function validateLspProfileDefinition(profile: LspProfileDefinition): str return `LSP profile "${profile.name}" needs at least one file extension.`; } + if (requiresTypeScriptLanguageServerStdio(profile.command) + && !normalizeStringArray(profile.args).some((arg) => arg.toLowerCase() === '--stdio')) { + return `LSP profile "${profile.name}" needs the "--stdio" argument.`; + } + return undefined; } @@ -168,6 +173,18 @@ function normalizeFileExtensions(fileExtensions: string[]): string[] { return normalizeStringArray(fileExtensions).map((value) => (value.startsWith('.') ? value : `.${value}`)); } +function requiresTypeScriptLanguageServerStdio(command: string): boolean { + const executableName = command + .trim() + .split(/[\\/]/) + .at(-1) + ?.toLowerCase(); + + return executableName === 'typescript-language-server' + || executableName === 'typescript-language-server.cmd' + || executableName === 'typescript-language-server.exe'; +} + function normalizeStringArray(values?: ReadonlyArray): string[] { if (!values) { return []; diff --git a/tests/shared/tooling.test.ts b/tests/shared/tooling.test.ts index 98fe44d..7fa6c1c 100644 --- a/tests/shared/tooling.test.ts +++ b/tests/shared/tooling.test.ts @@ -135,7 +135,23 @@ describe('tooling settings helpers', () => { validateLspProfileDefinition({ ...profile, command: 'typescript-language-server', + args: ['--stdio'], }), ).toBe('LSP profile "TypeScript" needs at least one file extension.'); }); + + test('requires the stdio flag for the TypeScript language server profile', () => { + expect( + validateLspProfileDefinition({ + id: 'lsp-ts', + name: 'Typescript LSP', + command: 'typescript-language-server', + args: [], + languageId: 'typescript', + fileExtensions: ['.ts', '.tsx'], + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + }), + ).toBe('LSP profile "Typescript LSP" needs the "--stdio" argument.'); + }); });