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>
This commit is contained in:
David Kaya
2026-03-23 23:16:48 +01:00
co-authored by Copilot
parent fe7155764c
commit cf699b2442
5 changed files with 110 additions and 4 deletions
@@ -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<string> _openedDocumentUris = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentQueue<string> _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<string> ResolveProcessArguments(RunTurnLspProfileConfigDto profile)
{
List<string> 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;
@@ -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<string> args = LspToolSession.ResolveProcessArguments(profile);
Assert.Equal(["--stdio"], args);
}
[Fact]
public void ResolveProcessArguments_DoesNotDuplicateStdioWhenAlreadyPresent()
{
RunTurnLspProfileConfigDto profile = new()
{
Command = "typescript-language-server",
Args = ["--stdio"],
};
IReadOnlyList<string> args = LspToolSession.ResolveProcessArguments(profile);
Assert.Equal(["--stdio"], args);
}
}
+1 -1
View File
@@ -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,
+17
View File
@@ -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>): string[] {
if (!values) {
return [];
+16
View File
@@ -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.');
});
});