mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-28 13:47:12 +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 _writeLock = new(1, 1);
|
||||||
private readonly SemaphoreSlim _documentLock = new(1, 1);
|
private readonly SemaphoreSlim _documentLock = new(1, 1);
|
||||||
private readonly HashSet<string> _openedDocumentUris = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _openedDocumentUris = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ConcurrentQueue<string> _stderrLines = new();
|
||||||
private readonly Task _stdoutReaderTask;
|
private readonly Task _stdoutReaderTask;
|
||||||
private readonly Task _stderrReaderTask;
|
private readonly Task _stderrReaderTask;
|
||||||
private int _nextRequestId;
|
private int _nextRequestId;
|
||||||
@@ -97,7 +98,7 @@ internal sealed class LspToolSession : IAsyncDisposable
|
|||||||
CreateNoWindow = true,
|
CreateNoWindow = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (string arg in profile.Args)
|
foreach (string arg in ResolveProcessArguments(profile))
|
||||||
{
|
{
|
||||||
startInfo.ArgumentList.Add(arg);
|
startInfo.ArgumentList.Add(arg);
|
||||||
}
|
}
|
||||||
@@ -125,6 +126,19 @@ internal sealed class LspToolSession : IAsyncDisposable
|
|||||||
return session;
|
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()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
_cts.Cancel();
|
_cts.Cancel();
|
||||||
@@ -483,8 +497,7 @@ internal sealed class LspToolSession : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
FailPendingRequests(new InvalidOperationException(
|
FailPendingRequests(CreatePendingRequestInterruptedException());
|
||||||
$"LSP profile \"{_profile.Name}\" stopped before a pending request completed."));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,6 +515,7 @@ internal sealed class LspToolSession : IAsyncDisposable
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(line))
|
if (!string.IsNullOrWhiteSpace(line))
|
||||||
{
|
{
|
||||||
|
RecordStderrLine(line);
|
||||||
Console.Error.WriteLine($"[eryx lsp:{_profile.Id}] {line}");
|
Console.Error.WriteLine($"[eryx lsp:{_profile.Id}] {line}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,6 +616,28 @@ internal sealed class LspToolSession : IAsyncDisposable
|
|||||||
_pending.Clear();
|
_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)
|
private string ResolveProjectPath(string relativePath)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(relativePath))
|
if (string.IsNullOrWhiteSpace(relativePath))
|
||||||
@@ -667,6 +703,14 @@ internal sealed class LspToolSession : IAsyncDisposable
|
|||||||
return Math.Max(value - 1, 0);
|
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)
|
private static string ToFileUri(string path)
|
||||||
{
|
{
|
||||||
return new Uri(path).AbsoluteUri;
|
return new Uri(path).AbsoluteUri;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Eryx.AgentHost.Contracts;
|
||||||
using Eryx.AgentHost.Services;
|
using Eryx.AgentHost.Services;
|
||||||
|
|
||||||
namespace Eryx.AgentHost.Tests;
|
namespace Eryx.AgentHost.Tests;
|
||||||
@@ -24,4 +25,32 @@ public sealed class LspToolSessionTests
|
|||||||
|
|
||||||
Assert.Contains("relativePath", json);
|
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'),
|
id: createId('lsp'),
|
||||||
name: 'New LSP Profile',
|
name: 'New LSP Profile',
|
||||||
command: '',
|
command: '',
|
||||||
args: [],
|
args: ['--stdio'],
|
||||||
languageId: 'typescript',
|
languageId: 'typescript',
|
||||||
fileExtensions: ['.ts', '.tsx'],
|
fileExtensions: ['.ts', '.tsx'],
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ export function validateLspProfileDefinition(profile: LspProfileDefinition): str
|
|||||||
return `LSP profile "${profile.name}" needs at least one file extension.`;
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +173,18 @@ function normalizeFileExtensions(fileExtensions: string[]): string[] {
|
|||||||
return normalizeStringArray(fileExtensions).map((value) => (value.startsWith('.') ? value : `.${value}`));
|
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[] {
|
function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
|
||||||
if (!values) {
|
if (!values) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -135,7 +135,23 @@ describe('tooling settings helpers', () => {
|
|||||||
validateLspProfileDefinition({
|
validateLspProfileDefinition({
|
||||||
...profile,
|
...profile,
|
||||||
command: 'typescript-language-server',
|
command: 'typescript-language-server',
|
||||||
|
args: ['--stdio'],
|
||||||
}),
|
}),
|
||||||
).toBe('LSP profile "TypeScript" needs at least one file extension.');
|
).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