Restart a crashed language server per --restart never|always|N
The process is supervised: on exit after a successful initialize it is spawned again with exponential backoff (500ms to 30s), open documents are re-sent as didOpen before the new session is announced, and requests caught by the crash wait for it and retry. A crash more than a minute after the previous one starts a fresh streak; reaching the streak limit (default 3) ends restarting for good, after which every request fails with an LspError saying so. Restarts and give-ups are logged and forwarded to the MCP client. Writes to the child's stdin go through a PassThrough so a broken pipe ends the session instead of surfacing as an unhandled rejection.
This commit is contained in:
6 files changed
+386
-91
No files matched your search
@@ -24,7 +24,8 @@ src/Resources.ts MCP resources (lsp://server, lsp://diagnostics[/{file}]) a
|
|||||||
src/Server.ts layer composition: LanguageServer → Documents+Diagnostics → transport → tools/resources
|
src/Server.ts layer composition: LanguageServer → Documents+Diagnostics → transport → tools/resources
|
||||||
src/main.ts CLI entry (Command/Flag/Argument), logs to stderr, NodeRuntime.runMain
|
src/main.ts CLI entry (Command/Flag/Argument), logs to stderr, NodeRuntime.runMain
|
||||||
src/lsp/ the ONLY place vscode-* packages are used at runtime
|
src/lsp/ the ONLY place vscode-* packages are used at runtime
|
||||||
LanguageServer.ts spawn + JSON-RPC + initialize/shutdown; request() waits for $/progress idle and retries ContentModified
|
LanguageServer.ts spawn + JSON-RPC + initialize/shutdown, supervised: restarts on crash per policy, reopens documents
|
||||||
|
request() waits for $/progress idle and retries ContentModified / a server that is being restarted
|
||||||
Documents.ts on-demand didOpen, LRU via ScopedCache, disk change detection → didChange+didSave, applyEdit
|
Documents.ts on-demand didOpen, LRU via ScopedCache, disk change detection → didChange+didSave, applyEdit
|
||||||
Diagnostics.ts publishDiagnostics accumulation; pull (textDocument/diagnostic) or settled push per doc version
|
Diagnostics.ts publishDiagnostics accumulation; pull (textDocument/diagnostic) or settled push per doc version
|
||||||
Editor.ts the operations = MCP tool handlers; LSP results → Model values
|
Editor.ts the operations = MCP tool handlers; LSP results → Model values
|
||||||
@@ -48,6 +49,12 @@ Rule: outside `src/lsp/`, `vscode-*` may only be imported as types.
|
|||||||
plus a 250ms quiet period, bounded by 5s, memoized per (uri, version).
|
plus a 250ms quiet period, bounded by 5s, memoized per (uri, version).
|
||||||
- Tests that talk to the fixture server use `it.layer(..., { excludeTestServices: true })` so the real
|
- Tests that talk to the fixture server use `it.layer(..., { excludeTestServices: true })` so the real
|
||||||
clock is used (Stream.debounce / timeouts would hang under TestClock).
|
clock is used (Stream.debounce / timeouts would hang under TestClock).
|
||||||
|
- Crashes: `--restart never|always|N` (default 3). A crash is the process exiting or closing the connection after a
|
||||||
|
successful initialize; a server that cannot initialize fails lsp-mcp immediately. Restarts back off from 500ms to 30s,
|
||||||
|
a crash more than a minute after the previous one starts a new streak, and the streak limit `N` ends restarting for good.
|
||||||
|
Requests in flight during a crash wait for the new process and retry; documents are re-`didOpen`ed with their current
|
||||||
|
text; the capabilities and tool set stay those of the first process. Each restart and the final give-up are logged and
|
||||||
|
sent to the MCP client as `notifications/message`. Once given up, every tool call fails with an `LspError` that says so.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -55,5 +62,4 @@ Complete for the first release: 13 tools, 2 resources + 1 template, stdio and HT
|
|||||||
verified against the fixture server and `typescript-language-server`.
|
verified against the fixture server and `typescript-language-server`.
|
||||||
|
|
||||||
Ideas not done: inlay hints, type hierarchy, semantic tokens, partial results, workspace/didChangeWatchedFiles
|
Ideas not done: inlay hints, type hierarchy, semantic tokens, partial results, workspace/didChangeWatchedFiles
|
||||||
when a server insists on it, restarting a crashed language server (currently requests fail with a clear
|
when a server insists on it.
|
||||||
`LspError` until the MCP client restarts the process).
|
|
||||||
+309
-84
@@ -5,12 +5,20 @@
|
|||||||
* `request` waits for the server to become idle (no `$/progress` work in
|
* `request` waits for the server to become idle (no `$/progress` work in
|
||||||
* flight) and retries transient `ContentModified` / `ServerCancelled`
|
* flight) and retries transient `ContentModified` / `ServerCancelled`
|
||||||
* failures, so callers can treat the server as if it were always ready.
|
* failures, so callers can treat the server as if it were always ready.
|
||||||
|
*
|
||||||
|
* The process is supervised: when it exits unexpectedly it is started again
|
||||||
|
* according to the restart policy, the documents that were open are opened in
|
||||||
|
* the new process, and requests caught by the crash wait for it and retry.
|
||||||
|
* Once the policy gives up, every request fails with an `LspError` saying so.
|
||||||
*/
|
*/
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
import { NodeStream } from "@effect/platform-node";
|
import { NodeStream } from "@effect/platform-node";
|
||||||
import {
|
import {
|
||||||
|
Cause,
|
||||||
Context,
|
Context,
|
||||||
Deferred,
|
Deferred,
|
||||||
|
Duration,
|
||||||
Effect,
|
Effect,
|
||||||
FiberSet,
|
FiberSet,
|
||||||
HashSet,
|
HashSet,
|
||||||
@@ -26,14 +34,22 @@ import {
|
|||||||
CancellationTokenSource,
|
CancellationTokenSource,
|
||||||
type ClientCapabilities,
|
type ClientCapabilities,
|
||||||
type CompletionItemKind,
|
type CompletionItemKind,
|
||||||
|
type ConfigurationParams,
|
||||||
ConfigurationRequest,
|
ConfigurationRequest,
|
||||||
createMessageConnection,
|
createMessageConnection,
|
||||||
DidChangeConfigurationNotification,
|
DidChangeConfigurationNotification,
|
||||||
|
DidChangeTextDocumentNotification,
|
||||||
|
type DidChangeTextDocumentParams,
|
||||||
|
DidCloseTextDocumentNotification,
|
||||||
|
type DidCloseTextDocumentParams,
|
||||||
|
DidOpenTextDocumentNotification,
|
||||||
|
type DidOpenTextDocumentParams,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
ExitNotification,
|
ExitNotification,
|
||||||
type HandlerResult,
|
type HandlerResult,
|
||||||
InitializedNotification,
|
InitializedNotification,
|
||||||
InitializeRequest,
|
InitializeRequest,
|
||||||
|
type InitializeResult,
|
||||||
LogMessageNotification,
|
LogMessageNotification,
|
||||||
type LSPAny,
|
type LSPAny,
|
||||||
LSPErrorCodes,
|
LSPErrorCodes,
|
||||||
@@ -48,12 +64,20 @@ import {
|
|||||||
StreamMessageReader,
|
StreamMessageReader,
|
||||||
StreamMessageWriter,
|
StreamMessageWriter,
|
||||||
type SymbolKind,
|
type SymbolKind,
|
||||||
|
type TextDocumentItem,
|
||||||
UnregistrationRequest,
|
UnregistrationRequest,
|
||||||
WorkDoneProgressCreateRequest,
|
WorkDoneProgressCreateRequest,
|
||||||
WorkspaceFoldersRequest,
|
WorkspaceFoldersRequest,
|
||||||
} from "vscode-languageserver-protocol/node";
|
} from "vscode-languageserver-protocol/node";
|
||||||
import { URI } from "vscode-uri";
|
import { URI } from "vscode-uri";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to do when the server exits unexpectedly: never start it again, always
|
||||||
|
* start it again, or start it again but give up after this many crashes in a
|
||||||
|
* row (less than a minute apart).
|
||||||
|
*/
|
||||||
|
export type Restart = "never" | "always" | number;
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
readonly command: string;
|
readonly command: string;
|
||||||
readonly args: ReadonlyArray<string>;
|
readonly args: ReadonlyArray<string>;
|
||||||
@@ -62,18 +86,26 @@ export interface Options {
|
|||||||
readonly initializationOptions?: unknown;
|
readonly initializationOptions?: unknown;
|
||||||
/** Served as `workspace/configuration` and pushed via `didChangeConfiguration`. */
|
/** Served as `workspace/configuration` and pushed via `didChangeConfiguration`. */
|
||||||
readonly settings?: unknown;
|
readonly settings?: unknown;
|
||||||
|
/** Defaults to giving up after 3 crashes in a row. */
|
||||||
|
readonly restart?: Restart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Failures worth retrying: the server was busy, or it went away and may come back. */
|
||||||
|
const transient: ReadonlySet<number> = new Set([
|
||||||
|
LSPErrorCodes.ContentModified,
|
||||||
|
LSPErrorCodes.ServerCancelled,
|
||||||
|
ErrorCodes.ConnectionInactive,
|
||||||
|
ErrorCodes.PendingResponseRejected,
|
||||||
|
ErrorCodes.MessageWriteError,
|
||||||
|
]);
|
||||||
|
|
||||||
export class LspError extends Schema.TaggedError<LspError>()("LspError", {
|
export class LspError extends Schema.TaggedError<LspError>()("LspError", {
|
||||||
method: Schema.String,
|
method: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
code: Schema.optional(Schema.Int),
|
code: Schema.optional(Schema.Int),
|
||||||
}) {
|
}) {
|
||||||
get retryable() {
|
get retryable() {
|
||||||
return (
|
return this.code !== undefined && transient.has(this.code);
|
||||||
this.code === LSPErrorCodes.ContentModified ||
|
|
||||||
this.code === LSPErrorCodes.ServerCancelled
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +137,7 @@ export class LanguageServer extends Context.Service<
|
|||||||
type: RequestType<P, R, unknown>,
|
type: RequestType<P, R, unknown>,
|
||||||
handler: (params: P) => Effect.Effect<R, unknown>,
|
handler: (params: P) => Effect.Effect<R, unknown>,
|
||||||
) => Effect.Effect<void, never, Scope.Scope>;
|
) => Effect.Effect<void, never, Scope.Scope>;
|
||||||
/** `window/logMessage` and `window/showMessage` traffic. */
|
/** `window/logMessage` and `window/showMessage` traffic, plus restart announcements. */
|
||||||
readonly logs: Stream.Stream<LogMessage>;
|
readonly logs: Stream.Stream<LogMessage>;
|
||||||
}
|
}
|
||||||
>()("lsp-mcp/lsp/LanguageServer") {
|
>()("lsp-mcp/lsp/LanguageServer") {
|
||||||
@@ -113,22 +145,97 @@ export class LanguageServer extends Context.Service<
|
|||||||
Layer.effect(LanguageServer, make(options));
|
Layer.effect(LanguageServer, make(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One process and its connection; replaced on crash. */
|
||||||
|
interface Session {
|
||||||
|
readonly initialized: InitializeResult;
|
||||||
|
readonly exited: Deferred.Deferred<never, LspError>;
|
||||||
|
readonly send: <P, R>(
|
||||||
|
type: RequestType<P, R, unknown>,
|
||||||
|
params: P,
|
||||||
|
) => Effect.Effect<R, LspError>;
|
||||||
|
readonly notify: <P>(
|
||||||
|
type: NotificationType<P>,
|
||||||
|
params: P,
|
||||||
|
) => Effect.Effect<void, LspError>;
|
||||||
|
}
|
||||||
|
|
||||||
const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
|
const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
|
||||||
const run = yield* FiberSet.makeRuntime<never>();
|
const run = yield* FiberSet.makeRuntime<never>();
|
||||||
const runPromise = yield* FiberSet.makeRuntimePromise<never>();
|
const runPromise = yield* FiberSet.makeRuntimePromise<never>();
|
||||||
const exited = yield* Deferred.make<never, LspError>();
|
|
||||||
const events = yield* PubSub.unbounded<{
|
const events = yield* PubSub.unbounded<{
|
||||||
readonly method: string;
|
readonly method: string;
|
||||||
readonly params: unknown;
|
readonly params: unknown;
|
||||||
}>();
|
}>();
|
||||||
|
const lifecycle = yield* PubSub.unbounded<LogMessage>();
|
||||||
const progress = yield* SubscriptionRef.make(
|
const progress = yield* SubscriptionRef.make(
|
||||||
HashSet.empty<string | number>(),
|
HashSet.empty<string | number>(),
|
||||||
);
|
);
|
||||||
const exit = (message: string) =>
|
const initialized = yield* Deferred.make<InitializeResult, LspError>();
|
||||||
|
/** The live session, or the one being started; failed once the policy gives up. */
|
||||||
|
let current = Deferred.makeUnsafe<Session, LspError>();
|
||||||
|
const session = Effect.suspend(() => Deferred.await(current));
|
||||||
|
/** What the server has been told is open, to open it again after a restart. */
|
||||||
|
const documents = new Map<string, TextDocumentItem>();
|
||||||
|
const limit =
|
||||||
|
options.restart === "never"
|
||||||
|
? 0
|
||||||
|
: options.restart === "always"
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: (options.restart ?? 3);
|
||||||
|
|
||||||
|
const rootUri = URI.file(options.root).toString();
|
||||||
|
const workspaceFolders = [
|
||||||
|
{ uri: rootUri, name: options.root.split(/[\\/]/).pop() ?? options.root },
|
||||||
|
];
|
||||||
|
const handlers = new Map<
|
||||||
|
string,
|
||||||
|
(params: unknown) => HandlerResult<unknown, unknown>
|
||||||
|
>([
|
||||||
|
[WorkDoneProgressCreateRequest.method, () => undefined],
|
||||||
|
[RegistrationRequest.method, () => undefined],
|
||||||
|
[UnregistrationRequest.method, () => undefined],
|
||||||
|
[
|
||||||
|
ConfigurationRequest.method,
|
||||||
|
(params) =>
|
||||||
|
(params as ConfigurationParams).items.map((item) =>
|
||||||
|
section(options.settings, item.section),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[WorkspaceFoldersRequest.method, () => workspaceFolders],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const failure = (method: string, error: unknown) =>
|
||||||
|
error instanceof ResponseError
|
||||||
|
? new LspError({ method, message: error.message, code: error.code })
|
||||||
|
: new LspError({
|
||||||
|
method,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
const announce = (level: LogLevel, message: string) =>
|
||||||
|
Effect.andThen(
|
||||||
|
level === "error" ? Effect.logError(message) : Effect.logWarning(message),
|
||||||
|
PubSub.publish(lifecycle, { level, message }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = Effect.fn("LanguageServer.start")(function* () {
|
||||||
|
const exited = yield* Deferred.make<never, LspError>();
|
||||||
|
const exit = (message: string) => {
|
||||||
|
if (Deferred.isDoneUnsafe(exited)) return;
|
||||||
|
// Whoever was using this session now waits for the next one; swap before
|
||||||
|
// signalling, as waiters resume synchronously and may give up right away.
|
||||||
|
if (Deferred.isDoneUnsafe(current)) current = Deferred.makeUnsafe();
|
||||||
Deferred.doneUnsafe(
|
Deferred.doneUnsafe(
|
||||||
exited,
|
exited,
|
||||||
Effect.fail(new LspError({ method: "connection", message })),
|
Effect.fail(
|
||||||
|
new LspError({
|
||||||
|
method: "connection",
|
||||||
|
message,
|
||||||
|
code: ErrorCodes.ConnectionInactive,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const child = yield* Effect.acquireRelease(
|
const child = yield* Effect.acquireRelease(
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
@@ -157,9 +264,16 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
|
|||||||
Effect.forkScoped,
|
Effect.forkScoped,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Writes go through a buffer: a broken pipe must end the session, not
|
||||||
|
// surface as a write failure (vscode-jsonrpc leaks those as unhandled rejections).
|
||||||
|
const input = new PassThrough();
|
||||||
|
input.pipe(child.stdin);
|
||||||
|
child.stdin.on("error", (error) =>
|
||||||
|
exit(`${options.command}: ${error.message}`),
|
||||||
|
);
|
||||||
const connection = createMessageConnection(
|
const connection = createMessageConnection(
|
||||||
new StreamMessageReader(child.stdout),
|
new StreamMessageReader(child.stdout),
|
||||||
new StreamMessageWriter(child.stdin),
|
new StreamMessageWriter(input),
|
||||||
{
|
{
|
||||||
error: (m) => run(Effect.logError(m)),
|
error: (m) => run(Effect.logError(m)),
|
||||||
warn: (m) => run(Effect.logWarning(m)),
|
warn: (m) => run(Effect.logWarning(m)),
|
||||||
@@ -183,32 +297,20 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
connection.onRequest(WorkDoneProgressCreateRequest.type, () => undefined);
|
connection.onRequest(
|
||||||
connection.onRequest(RegistrationRequest.type, () => undefined);
|
(method, params) =>
|
||||||
connection.onRequest(UnregistrationRequest.type, () => undefined);
|
handlers.get(method)?.(params) ??
|
||||||
connection.onRequest(ConfigurationRequest.type, ({ items }) =>
|
new ResponseError(ErrorCodes.MethodNotFound, `unhandled ${method}`),
|
||||||
items.map((item) => section(options.settings, item.section)),
|
|
||||||
);
|
);
|
||||||
const rootUri = URI.file(options.root).toString();
|
|
||||||
const workspaceFolders = [
|
|
||||||
{ uri: rootUri, name: options.root.split(/[\\/]/).pop() ?? options.root },
|
|
||||||
];
|
|
||||||
connection.onRequest(WorkspaceFoldersRequest.type, () => workspaceFolders);
|
|
||||||
connection.listen();
|
connection.listen();
|
||||||
|
|
||||||
const failure = (method: string, error: unknown) =>
|
|
||||||
error instanceof ResponseError
|
|
||||||
? new LspError({ method, message: error.message, code: error.code })
|
|
||||||
: new LspError({
|
|
||||||
method,
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
|
|
||||||
const send = <P, R>(type: RequestType<P, R, unknown>, params: P) =>
|
const send = <P, R>(type: RequestType<P, R, unknown>, params: P) =>
|
||||||
Effect.callback<R, LspError>((resume) => {
|
Effect.callback<R, LspError>((resume) => {
|
||||||
const cancellation = new CancellationTokenSource();
|
const cancellation = new CancellationTokenSource();
|
||||||
try {
|
try {
|
||||||
connection.sendRequest(type, params as never, cancellation.token).then(
|
connection
|
||||||
|
.sendRequest(type, params as never, cancellation.token)
|
||||||
|
.then(
|
||||||
(result) => resume(Effect.succeed(result)),
|
(result) => resume(Effect.succeed(result)),
|
||||||
(error) => resume(Effect.fail(failure(type.method, error))),
|
(error) => resume(Effect.fail(failure(type.method, error))),
|
||||||
);
|
);
|
||||||
@@ -218,62 +320,13 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
|
|||||||
return Effect.ignore(Effect.try(() => cancellation.cancel()));
|
return Effect.ignore(Effect.try(() => cancellation.cancel()));
|
||||||
}).pipe(Effect.raceFirst(Deferred.await(exited)));
|
}).pipe(Effect.raceFirst(Deferred.await(exited)));
|
||||||
|
|
||||||
const idle = SubscriptionRef.changes(progress).pipe(
|
|
||||||
Stream.filter(HashSet.isEmpty),
|
|
||||||
Stream.runHead,
|
|
||||||
Effect.timeoutOption("15 seconds"),
|
|
||||||
Effect.asVoid,
|
|
||||||
);
|
|
||||||
|
|
||||||
const request = <P, R>(type: RequestType<P, R, unknown>, params: P) =>
|
|
||||||
idle.pipe(
|
|
||||||
Effect.andThen(send(type, params)),
|
|
||||||
Effect.retry({
|
|
||||||
while: (error) => error.retryable,
|
|
||||||
schedule: Schedule.exponential("100 millis").pipe(
|
|
||||||
Schedule.upTo({ times: 5 }),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const notify = <P>(type: NotificationType<P>, params: P) =>
|
const notify = <P>(type: NotificationType<P>, params: P) =>
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
try: () => connection.sendNotification(type, params as never),
|
try: () => connection.sendNotification(type, params as never),
|
||||||
catch: (error) => failure(type.method, error),
|
catch: (error) => failure(type.method, error),
|
||||||
}).pipe(Effect.catch((error) => Effect.logWarning(error.message)));
|
});
|
||||||
|
|
||||||
const notifications = <P>(type: NotificationType<P>): Stream.Stream<P> =>
|
|
||||||
Stream.fromPubSub(events).pipe(
|
|
||||||
Stream.filter((event) => event.method === type.method),
|
|
||||||
Stream.map((event) => event.params as P),
|
|
||||||
);
|
|
||||||
|
|
||||||
const onRequest = <P, R>(
|
|
||||||
type: RequestType<P, R, unknown>,
|
|
||||||
handler: (params: P) => Effect.Effect<R, unknown>,
|
|
||||||
) =>
|
|
||||||
Effect.acquireRelease(
|
|
||||||
Effect.sync(() =>
|
|
||||||
connection.onRequest(
|
|
||||||
type.method,
|
|
||||||
(params: P): HandlerResult<R, unknown> =>
|
|
||||||
runPromise(
|
|
||||||
handler(params).pipe(
|
|
||||||
Effect.mapError((error) =>
|
|
||||||
error instanceof ResponseError
|
|
||||||
? error
|
|
||||||
: new ResponseError(
|
|
||||||
ErrorCodes.InternalError,
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) as Promise<never>,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(disposable) => Effect.sync(() => disposable.dispose()),
|
|
||||||
).pipe(Effect.asVoid);
|
|
||||||
|
|
||||||
|
yield* SubscriptionRef.set(progress, HashSet.empty());
|
||||||
const initialized = yield* send(InitializeRequest.type, {
|
const initialized = yield* send(InitializeRequest.type, {
|
||||||
processId: process.pid,
|
processId: process.pid,
|
||||||
clientInfo: { name: "lsp-mcp", version: "0.1.0" },
|
clientInfo: { name: "lsp-mcp", version: "0.1.0" },
|
||||||
@@ -290,8 +343,15 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
|
|||||||
settings: options.settings as LSPAny,
|
settings: options.settings as LSPAny,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.forEach(
|
||||||
Effect.tryPromise(() => connection.sendRequest(ShutdownRequest.type)).pipe(
|
documents.values(),
|
||||||
|
(textDocument) =>
|
||||||
|
notify(DidOpenTextDocumentNotification.type, { textDocument }),
|
||||||
|
{ discard: true },
|
||||||
|
);
|
||||||
|
const shutdown = Effect.tryPromise(() =>
|
||||||
|
connection.sendRequest(ShutdownRequest.type),
|
||||||
|
).pipe(
|
||||||
Effect.timeout("3 seconds"),
|
Effect.timeout("3 seconds"),
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
Effect.tryPromise(() =>
|
Effect.tryPromise(() =>
|
||||||
@@ -300,8 +360,114 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
|
|||||||
),
|
),
|
||||||
Effect.andThen(Deferred.await(exited).pipe(Effect.timeout("2 seconds"))),
|
Effect.andThen(Deferred.await(exited).pipe(Effect.timeout("2 seconds"))),
|
||||||
Effect.ignore,
|
Effect.ignore,
|
||||||
|
);
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.suspend(() =>
|
||||||
|
Deferred.isDoneUnsafe(exited) ? Effect.void : shutdown,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return { initialized, exited, send, notify } satisfies Session;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Runs one session until its process exits, and fails with the reason. */
|
||||||
|
const serve = Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* start().pipe(
|
||||||
|
Effect.tapError((error) => Deferred.fail(initialized, error)),
|
||||||
|
);
|
||||||
|
yield* Deferred.succeed(current, session);
|
||||||
|
yield* Deferred.succeed(initialized, session.initialized);
|
||||||
|
return yield* Deferred.await(session.exited);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const policy = restarts(limit).pipe(
|
||||||
|
// Only crashes are worth retrying; a server that fails to initialize is broken.
|
||||||
|
Schedule.while(({ input }) => input.code === ErrorCodes.ConnectionInactive),
|
||||||
|
Schedule.tap(({ input, output, duration }) =>
|
||||||
|
announce(
|
||||||
|
"warning",
|
||||||
|
`${input.message}; restarting in ${Duration.format(duration)}${
|
||||||
|
Number.isFinite(limit) ? ` (crash ${output} of ${limit})` : ""
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const giveUp = (error: LspError) => {
|
||||||
|
const reason =
|
||||||
|
error.code !== ErrorCodes.ConnectionInactive
|
||||||
|
? `restarting ${options.command} failed (${error.method}: ${error.message})`
|
||||||
|
: limit === 0
|
||||||
|
? `${error.message}; restarting is disabled`
|
||||||
|
: `${error.message}; that is ${limit} crash${limit === 1 ? "" : "es"} in a row`;
|
||||||
|
const message = `${reason}. Restart lsp-mcp to recover`;
|
||||||
|
return Effect.andThen(
|
||||||
|
announce("error", message),
|
||||||
|
Deferred.fail(current, new LspError({ method: "connection", message })),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
yield* serve.pipe(Effect.retryOrElse(policy, giveUp), Effect.forkScoped);
|
||||||
|
const first = yield* Deferred.await(initialized);
|
||||||
|
|
||||||
|
const idle = SubscriptionRef.changes(progress).pipe(
|
||||||
|
Stream.filter(HashSet.isEmpty),
|
||||||
|
Stream.runHead,
|
||||||
|
Effect.timeoutOption("15 seconds"),
|
||||||
|
Effect.asVoid,
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = <P, R>(type: RequestType<P, R, unknown>, params: P) =>
|
||||||
|
session.pipe(
|
||||||
|
Effect.tap(() => idle),
|
||||||
|
Effect.flatMap((session) => session.send(type, params)),
|
||||||
|
Effect.retry({
|
||||||
|
while: (error) => error.retryable,
|
||||||
|
schedule: Schedule.exponential("100 millis").pipe(
|
||||||
|
Schedule.upTo({ times: 5 }),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const notify = <P>(type: NotificationType<P>, params: P) =>
|
||||||
|
Effect.sync(() => track(documents, type.method, params)).pipe(
|
||||||
|
Effect.andThen(session),
|
||||||
|
Effect.flatMap((session) => session.notify(type, params)),
|
||||||
|
Effect.catch((error) => Effect.logWarning(error.message)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const notifications = <P>(type: NotificationType<P>): Stream.Stream<P> =>
|
||||||
|
Stream.fromPubSub(events).pipe(
|
||||||
|
Stream.filter((event) => event.method === type.method),
|
||||||
|
Stream.map((event) => event.params as P),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRequest = <P, R>(
|
||||||
|
type: RequestType<P, R, unknown>,
|
||||||
|
handler: (params: P) => Effect.Effect<R, unknown>,
|
||||||
|
) =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.sync(() =>
|
||||||
|
handlers.set(
|
||||||
|
type.method,
|
||||||
|
(params) =>
|
||||||
|
runPromise(
|
||||||
|
handler(params as P).pipe(
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
error instanceof ResponseError
|
||||||
|
? error
|
||||||
|
: new ResponseError(
|
||||||
|
ErrorCodes.InternalError,
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) as Promise<never>,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
() => Effect.sync(() => handlers.delete(type.method)),
|
||||||
|
).pipe(Effect.asVoid);
|
||||||
|
|
||||||
const logs = Stream.merge(
|
const logs = Stream.merge(
|
||||||
notifications(LogMessageNotification.type),
|
notifications(LogMessageNotification.type),
|
||||||
@@ -313,12 +479,13 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
|
|||||||
message,
|
message,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
Stream.merge(Stream.fromPubSub(lifecycle)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return LanguageServer.of({
|
return LanguageServer.of({
|
||||||
root: options.root,
|
root: options.root,
|
||||||
capabilities: initialized.capabilities,
|
capabilities: first.capabilities,
|
||||||
serverInfo: initialized.serverInfo,
|
serverInfo: first.serverInfo,
|
||||||
request,
|
request,
|
||||||
notify,
|
notify,
|
||||||
notifications,
|
notifications,
|
||||||
@@ -327,6 +494,61 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A crash this long after the previous one starts a fresh streak. */
|
||||||
|
const quiet = Duration.minutes(1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart delays: exponential from 500ms, capped at 30s, giving up once
|
||||||
|
* `limit` crashes happened in a row. The output is the length of the streak.
|
||||||
|
*/
|
||||||
|
const restarts = (limit: number) =>
|
||||||
|
Schedule.fromStepWithMetadata(
|
||||||
|
Effect.sync(() => {
|
||||||
|
let streak = 0;
|
||||||
|
return ({ elapsedSincePrevious }: Schedule.InputMetadata<LspError>) => {
|
||||||
|
streak =
|
||||||
|
elapsedSincePrevious > Duration.toMillis(quiet) ? 1 : streak + 1;
|
||||||
|
return streak > limit
|
||||||
|
? Cause.done(streak)
|
||||||
|
: Effect.succeed<[number, Duration.Duration]>([
|
||||||
|
streak,
|
||||||
|
Duration.millis(Math.min(500 * 2 ** (streak - 1), 30_000)),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Keep the open-document bookkeeping in step with what we tell the server. */
|
||||||
|
const track = (
|
||||||
|
documents: Map<string, TextDocumentItem>,
|
||||||
|
method: string,
|
||||||
|
params: unknown,
|
||||||
|
) => {
|
||||||
|
switch (method) {
|
||||||
|
case DidOpenTextDocumentNotification.method: {
|
||||||
|
const { textDocument } = params as DidOpenTextDocumentParams;
|
||||||
|
documents.set(textDocument.uri, textDocument);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DidChangeTextDocumentNotification.method: {
|
||||||
|
const { textDocument, contentChanges } =
|
||||||
|
params as DidChangeTextDocumentParams;
|
||||||
|
const open = documents.get(textDocument.uri);
|
||||||
|
const last = contentChanges.at(-1);
|
||||||
|
if (open && last && !("range" in last)) {
|
||||||
|
documents.set(textDocument.uri, {
|
||||||
|
...open,
|
||||||
|
version: textDocument.version,
|
||||||
|
text: last.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DidCloseTextDocumentNotification.method:
|
||||||
|
documents.delete((params as DidCloseTextDocumentParams).textDocument.uri);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const logLevels: Record<MessageType, LogLevel> = {
|
const logLevels: Record<MessageType, LogLevel> = {
|
||||||
1: "error",
|
1: "error",
|
||||||
2: "warning",
|
2: "warning",
|
||||||
@@ -395,7 +617,10 @@ const clientCapabilities: ClientCapabilities = {
|
|||||||
deprecatedSupport: true,
|
deprecatedSupport: true,
|
||||||
},
|
},
|
||||||
completionItemKind: {
|
completionItemKind: {
|
||||||
valueSet: Array.from({ length: 25 }, (_, i) => (i + 1) as CompletionItemKind),
|
valueSet: Array.from(
|
||||||
|
{ length: 25 },
|
||||||
|
(_, i) => (i + 1) as CompletionItemKind,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
contextSupport: true,
|
contextSupport: true,
|
||||||
},
|
},
|
||||||
|
|||||||
+22
@@ -10,6 +10,14 @@ import { Argument, Command, Flag } from "effect/unstable/cli";
|
|||||||
import * as Server from "./Server.ts";
|
import * as Server from "./Server.ts";
|
||||||
|
|
||||||
const Json = Flag.withSchema(Schema.fromJsonString(Schema.Json));
|
const Json = Flag.withSchema(Schema.fromJsonString(Schema.Json));
|
||||||
|
const crashes = { message: "expected a whole number of crashes" };
|
||||||
|
const Restart = Schema.Union([
|
||||||
|
Schema.Literals(["never", "always"]),
|
||||||
|
Schema.NumberFromString.check(
|
||||||
|
Schema.isInt(crashes),
|
||||||
|
Schema.isGreaterThanOrEqualTo(0, crashes),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const command = Command.make(
|
const command = Command.make(
|
||||||
"lsp-mcp",
|
"lsp-mcp",
|
||||||
@@ -58,6 +66,14 @@ const command = Command.make(
|
|||||||
),
|
),
|
||||||
Flag.withDefault(32),
|
Flag.withDefault(32),
|
||||||
),
|
),
|
||||||
|
restart: Flag.String("restart").pipe(
|
||||||
|
Flag.withSchema(Restart),
|
||||||
|
Flag.withMetavar("never|always|N"),
|
||||||
|
Flag.withDescription(
|
||||||
|
"When the language server crashes: never start it again, always start it again, or give up after N crashes in a row (default 3)",
|
||||||
|
),
|
||||||
|
Flag.withDefault(3),
|
||||||
|
),
|
||||||
command: Argument.String("server").pipe(
|
command: Argument.String("server").pipe(
|
||||||
Argument.withDescription("Language server executable"),
|
Argument.withDescription("Language server executable"),
|
||||||
),
|
),
|
||||||
@@ -75,6 +91,7 @@ const command = Command.make(
|
|||||||
settings,
|
settings,
|
||||||
languages,
|
languages,
|
||||||
capacity,
|
capacity,
|
||||||
|
restart,
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
}) {
|
}) {
|
||||||
@@ -89,6 +106,7 @@ const command = Command.make(
|
|||||||
root: path.resolve(root),
|
root: path.resolve(root),
|
||||||
initializationOptions: Option.getOrUndefined(initOptions),
|
initializationOptions: Option.getOrUndefined(initOptions),
|
||||||
settings: Option.getOrUndefined(settings),
|
settings: Option.getOrUndefined(settings),
|
||||||
|
restart,
|
||||||
},
|
},
|
||||||
documents: { capacity, languages },
|
documents: { capacity, languages },
|
||||||
}),
|
}),
|
||||||
@@ -111,6 +129,10 @@ const command = Command.make(
|
|||||||
command: `lsp-mcp --settings '{"gopls":{"staticcheck":true}}' -- gopls`,
|
command: `lsp-mcp --settings '{"gopls":{"staticcheck":true}}' -- gopls`,
|
||||||
description: "Go with workspace settings",
|
description: "Go with workspace settings",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
command: "lsp-mcp --restart always -- clangd",
|
||||||
|
description: "Keep bringing the server back, however often it crashes",
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+37
-1
@@ -1,7 +1,7 @@
|
|||||||
import { NodeServices } from "@effect/platform-node";
|
import { NodeServices } from "@effect/platform-node";
|
||||||
import { assert, describe, it } from "@effect/vitest";
|
import { assert, describe, it } from "@effect/vitest";
|
||||||
import { Effect, FileSystem, Layer } from "effect";
|
import { Effect, FileSystem, Layer } from "effect";
|
||||||
import { RequestType } from "vscode-languageserver-protocol";
|
import { NotificationType, RequestType } from "vscode-languageserver-protocol";
|
||||||
import { Diagnostics } from "../src/lsp/Diagnostics.ts";
|
import { Diagnostics } from "../src/lsp/Diagnostics.ts";
|
||||||
import { Documents } from "../src/lsp/Documents.ts";
|
import { Documents } from "../src/lsp/Documents.ts";
|
||||||
import * as Editor from "../src/lsp/Editor.ts";
|
import * as Editor from "../src/lsp/Editor.ts";
|
||||||
@@ -12,6 +12,7 @@ const State = new RequestType<null, { open: number; closes: number }, void>(
|
|||||||
"fixture/state",
|
"fixture/state",
|
||||||
);
|
);
|
||||||
const Progress = new RequestType<number, string, void>("fixture/progress");
|
const Progress = new RequestType<number, string, void>("fixture/progress");
|
||||||
|
const Crash = new NotificationType<null>("fixture/crash");
|
||||||
|
|
||||||
const TestLayer = Layer.mergeAll(
|
const TestLayer = Layer.mergeAll(
|
||||||
Documents.layer({ capacity: 2, languages: {} }),
|
Documents.layer({ capacity: 2, languages: {} }),
|
||||||
@@ -168,4 +169,39 @@ describe("Editor", () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.layer(
|
||||||
|
Layer.mergeAll(
|
||||||
|
Documents.layer({ capacity: 2, languages: {} }),
|
||||||
|
Diagnostics.layer,
|
||||||
|
).pipe(
|
||||||
|
Layer.provideMerge(
|
||||||
|
workspace({ "a.ts": "const alpha = 1\n" }, { restart: 1 }),
|
||||||
|
),
|
||||||
|
Layer.provideMerge(NodeServices.layer),
|
||||||
|
),
|
||||||
|
{ excludeTestServices: true },
|
||||||
|
)("when the server crashes", (it) => {
|
||||||
|
it.effect(
|
||||||
|
"starts it again with the same documents open, until the policy gives up",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const lsp = yield* LanguageServer;
|
||||||
|
const hover = Editor.hover({
|
||||||
|
file: "a.ts",
|
||||||
|
line: 1,
|
||||||
|
symbol: "alpha",
|
||||||
|
});
|
||||||
|
yield* hover;
|
||||||
|
|
||||||
|
yield* lsp.notify(Crash, null);
|
||||||
|
assert.strictEqual((yield* hover)?.contents, "word **alpha**");
|
||||||
|
assert.strictEqual((yield* lsp.request(State, null)).open, 1);
|
||||||
|
|
||||||
|
yield* lsp.notify(Crash, null);
|
||||||
|
const error = yield* Effect.flip(hover);
|
||||||
|
assert.match(error.message, /1 crash in a row/);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Vendored
+3
-1
@@ -2,7 +2,8 @@
|
|||||||
* A miniature language server used by the tests. Words are symbols: hover
|
* A miniature language server used by the tests. Words are symbols: hover
|
||||||
* describes the word under the cursor, definition is its first occurrence,
|
* describes the word under the cursor, definition is its first occurrence,
|
||||||
* references and rename cover every occurrence, and each line containing
|
* references and rename cover every occurrence, and each line containing
|
||||||
* "TODO" yields a warning. `fixture/state` reports open/close counts.
|
* "TODO" yields a warning. `fixture/state` reports open/close counts and
|
||||||
|
* `fixture/crash` kills the process.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
createConnection,
|
createConnection,
|
||||||
@@ -136,6 +137,7 @@ connection.onDocumentSymbol(({ textDocument }) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
connection.onNotification("fixture/crash", () => process.exit(1));
|
||||||
connection.onRequest("fixture/state", () => ({
|
connection.onRequest("fixture/state", () => ({
|
||||||
open: documents.all().length,
|
open: documents.all().length,
|
||||||
closes,
|
closes,
|
||||||
|
|||||||
Vendored
+6
-2
@@ -1,11 +1,14 @@
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Effect, FileSystem, Layer, Path } from "effect";
|
import { Effect, FileSystem, Layer, Path } from "effect";
|
||||||
import { LanguageServer } from "../../src/lsp/LanguageServer.ts";
|
import { LanguageServer, type Options } from "../../src/lsp/LanguageServer.ts";
|
||||||
|
|
||||||
const server = fileURLToPath(new URL("./server.ts", import.meta.url));
|
const server = fileURLToPath(new URL("./server.ts", import.meta.url));
|
||||||
|
|
||||||
/** A temporary workspace with the given files, served by the fixture language server. */
|
/** A temporary workspace with the given files, served by the fixture language server. */
|
||||||
export const workspace = (files: Record<string, string>) =>
|
export const workspace = (
|
||||||
|
files: Record<string, string>,
|
||||||
|
options?: Pick<Options, "restart">,
|
||||||
|
) =>
|
||||||
Layer.unwrap(
|
Layer.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem;
|
const fs = yield* FileSystem.FileSystem;
|
||||||
@@ -18,6 +21,7 @@ export const workspace = (files: Record<string, string>) =>
|
|||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
args: [server],
|
args: [server],
|
||||||
root,
|
root,
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
Reference in new issue
Block a user