mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 03:38:44 +02:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user