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:
EFLKumo committed 2026-09-20 15:36:30 +08:00
1 parent a89f862f73
commit 1463527870
6 files changed
+441 -146

No files matched your search

+9 -3
View File
@@ -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).
+364 -139
View File
@@ -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
);
} }
} }
@@ -89,8 +121,8 @@ export class LanguageServer extends Context.Service<
readonly root: string; readonly root: string;
readonly capabilities: ServerCapabilities; readonly capabilities: ServerCapabilities;
readonly serverInfo: readonly serverInfo:
| { readonly name: string; readonly version?: string | undefined } | { readonly name: string; readonly version?: string | undefined }
| undefined; | undefined;
readonly request: <P, R>( readonly request: <P, R>(
type: RequestType<P, R, unknown>, type: RequestType<P, R, unknown>,
params: P, params: P,
@@ -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,110 +145,271 @@ 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>();
Deferred.doneUnsafe( /** The live session, or the one being started; failed once the policy gives up. */
exited, let current = Deferred.makeUnsafe<Session, LspError>();
Effect.fail(new LspError({ method: "connection", message })), 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 child = yield* Effect.acquireRelease(
Effect.sync(() =>
spawn(options.command, options.args, {
cwd: options.root,
stdio: "pipe",
}),
),
(child) =>
Effect.sync(() => void (child.exitCode === null && child.kill())),
);
child.on("error", (error) =>
exit(`failed to start ${options.command}: ${error.message}`),
);
child.on("exit", (code, signal) =>
exit(`${options.command} exited (${signal ?? code})`),
);
yield* NodeStream.fromReadable<Uint8Array, never>({
evaluate: () => child.stderr,
onError: () => undefined as never,
}).pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => Effect.logDebug(line)),
Effect.annotateLogs("source", `${options.command}:stderr`),
Effect.forkScoped,
);
const connection = createMessageConnection(
new StreamMessageReader(child.stdout),
new StreamMessageWriter(child.stdin),
{
error: (m) => run(Effect.logError(m)),
warn: (m) => run(Effect.logWarning(m)),
info: (m) => run(Effect.logInfo(m)),
log: (m) => run(Effect.logDebug(m)),
},
);
yield* Effect.addFinalizer(() => Effect.sync(() => connection.dispose()));
connection.onClose(() => exit(`${options.command} closed the connection`));
connection.onNotification((method, params) => {
PubSub.publishUnsafe(events, { method, params });
});
connection.onUnhandledProgress(({ token, value }) =>
run(
SubscriptionRef.update(progress, (active) =>
value.kind === "begin"
? HashSet.add(active, token)
: value.kind === "end"
? HashSet.remove(active, token)
: active,
),
),
);
connection.onRequest(WorkDoneProgressCreateRequest.type, () => undefined);
connection.onRequest(RegistrationRequest.type, () => undefined);
connection.onRequest(UnregistrationRequest.type, () => undefined);
connection.onRequest(ConfigurationRequest.type, ({ items }) =>
items.map((item) => section(options.settings, item.section)),
);
const rootUri = URI.file(options.root).toString(); const rootUri = URI.file(options.root).toString();
const workspaceFolders = [ const workspaceFolders = [
{ uri: rootUri, name: options.root.split(/[\\/]/).pop() ?? options.root }, { uri: rootUri, name: options.root.split(/[\\/]/).pop() ?? options.root },
]; ];
connection.onRequest(WorkspaceFoldersRequest.type, () => workspaceFolders); const handlers = new Map<
connection.listen(); 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) => const failure = (method: string, error: unknown) =>
error instanceof ResponseError error instanceof ResponseError
? new LspError({ method, message: error.message, code: error.code }) ? new LspError({ method, message: error.message, code: error.code })
: new LspError({ : new LspError({
method, method,
message: error instanceof Error ? error.message : String(error), 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(
exited,
Effect.fail(
new LspError({
method: "connection",
message,
code: ErrorCodes.ConnectionInactive,
}),
),
);
};
const child = yield* Effect.acquireRelease(
Effect.sync(() =>
spawn(options.command, options.args, {
cwd: options.root,
stdio: "pipe",
}),
),
(child) =>
Effect.sync(() => void (child.exitCode === null && child.kill())),
);
child.on("error", (error) =>
exit(`failed to start ${options.command}: ${error.message}`),
);
child.on("exit", (code, signal) =>
exit(`${options.command} exited (${signal ?? code})`),
);
yield* NodeStream.fromReadable<Uint8Array, never>({
evaluate: () => child.stderr,
onError: () => undefined as never,
}).pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => Effect.logDebug(line)),
Effect.annotateLogs("source", `${options.command}:stderr`),
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(
new StreamMessageReader(child.stdout),
new StreamMessageWriter(input),
{
error: (m) => run(Effect.logError(m)),
warn: (m) => run(Effect.logWarning(m)),
info: (m) => run(Effect.logInfo(m)),
log: (m) => run(Effect.logDebug(m)),
},
);
yield* Effect.addFinalizer(() => Effect.sync(() => connection.dispose()));
connection.onClose(() => exit(`${options.command} closed the connection`));
connection.onNotification((method, params) => {
PubSub.publishUnsafe(events, { method, params });
});
connection.onUnhandledProgress(({ token, value }) =>
run(
SubscriptionRef.update(progress, (active) =>
value.kind === "begin"
? HashSet.add(active, token)
: value.kind === "end"
? HashSet.remove(active, token)
: active,
),
),
);
connection.onRequest(
(method, params) =>
handlers.get(method)?.(params) ??
new ResponseError(ErrorCodes.MethodNotFound, `unhandled ${method}`),
);
connection.listen();
const send = <P, R>(type: RequestType<P, R, unknown>, params: P) =>
Effect.callback<R, LspError>((resume) => {
const cancellation = new CancellationTokenSource();
try {
connection
.sendRequest(type, params as never, cancellation.token)
.then(
(result) => resume(Effect.succeed(result)),
(error) => resume(Effect.fail(failure(type.method, error))),
);
} catch (error) {
resume(Effect.fail(failure(type.method, error)));
}
return Effect.ignore(Effect.try(() => cancellation.cancel()));
}).pipe(Effect.raceFirst(Deferred.await(exited)));
const notify = <P>(type: NotificationType<P>, params: P) =>
Effect.tryPromise({
try: () => connection.sendNotification(type, params as never),
catch: (error) => failure(type.method, error),
}); });
const send = <P, R>(type: RequestType<P, R, unknown>, params: P) => yield* SubscriptionRef.set(progress, HashSet.empty());
Effect.callback<R, LspError>((resume) => { const initialized = yield* send(InitializeRequest.type, {
const cancellation = new CancellationTokenSource(); processId: process.pid,
try { clientInfo: { name: "lsp-mcp", version: "0.1.0" },
connection.sendRequest(type, params as never, cancellation.token).then( locale: "en",
(result) => resume(Effect.succeed(result)), rootPath: options.root,
(error) => resume(Effect.fail(failure(type.method, error))), rootUri,
); workspaceFolders,
} catch (error) { capabilities: clientCapabilities,
resume(Effect.fail(failure(type.method, error))); initializationOptions: options.initializationOptions as LSPAny,
} });
return Effect.ignore(Effect.try(() => cancellation.cancel())); yield* notify(InitializedNotification.type, {});
}).pipe(Effect.raceFirst(Deferred.await(exited))); if (options.settings !== undefined) {
yield* notify(DidChangeConfigurationNotification.type, {
settings: options.settings as LSPAny,
});
}
yield* Effect.forEach(
documents.values(),
(textDocument) =>
notify(DidOpenTextDocumentNotification.type, { textDocument }),
{ discard: true },
);
const shutdown = Effect.tryPromise(() =>
connection.sendRequest(ShutdownRequest.type),
).pipe(
Effect.timeout("3 seconds"),
Effect.andThen(
Effect.tryPromise(() =>
connection.sendNotification(ExitNotification.type),
),
),
Effect.andThen(Deferred.await(exited).pipe(Effect.timeout("2 seconds"))),
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( const idle = SubscriptionRef.changes(progress).pipe(
Stream.filter(HashSet.isEmpty), Stream.filter(HashSet.isEmpty),
@@ -226,8 +419,9 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
); );
const request = <P, R>(type: RequestType<P, R, unknown>, params: P) => const request = <P, R>(type: RequestType<P, R, unknown>, params: P) =>
idle.pipe( session.pipe(
Effect.andThen(send(type, params)), Effect.tap(() => idle),
Effect.flatMap((session) => session.send(type, params)),
Effect.retry({ Effect.retry({
while: (error) => error.retryable, while: (error) => error.retryable,
schedule: Schedule.exponential("100 millis").pipe( schedule: Schedule.exponential("100 millis").pipe(
@@ -237,10 +431,11 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
); );
const notify = <P>(type: NotificationType<P>, params: P) => const notify = <P>(type: NotificationType<P>, params: P) =>
Effect.tryPromise({ Effect.sync(() => track(documents, type.method, params)).pipe(
try: () => connection.sendNotification(type, params as never), Effect.andThen(session),
catch: (error) => failure(type.method, error), Effect.flatMap((session) => session.notify(type, params)),
}).pipe(Effect.catch((error) => Effect.logWarning(error.message))); Effect.catch((error) => Effect.logWarning(error.message)),
);
const notifications = <P>(type: NotificationType<P>): Stream.Stream<P> => const notifications = <P>(type: NotificationType<P>): Stream.Stream<P> =>
Stream.fromPubSub(events).pipe( Stream.fromPubSub(events).pipe(
@@ -254,55 +449,26 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
) => ) =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.sync(() => Effect.sync(() =>
connection.onRequest( handlers.set(
type.method, type.method,
(params: P): HandlerResult<R, unknown> => (params) =>
runPromise( runPromise(
handler(params).pipe( handler(params as P).pipe(
Effect.mapError((error) => Effect.mapError((error) =>
error instanceof ResponseError error instanceof ResponseError
? error ? error
: new ResponseError( : new ResponseError(
ErrorCodes.InternalError, ErrorCodes.InternalError,
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),
), ),
), ),
), ),
) as Promise<never>, ) as Promise<never>,
), ),
), ),
(disposable) => Effect.sync(() => disposable.dispose()), () => Effect.sync(() => handlers.delete(type.method)),
).pipe(Effect.asVoid); ).pipe(Effect.asVoid);
const initialized = yield* send(InitializeRequest.type, {
processId: process.pid,
clientInfo: { name: "lsp-mcp", version: "0.1.0" },
locale: "en",
rootPath: options.root,
rootUri,
workspaceFolders,
capabilities: clientCapabilities,
initializationOptions: options.initializationOptions as LSPAny,
});
yield* notify(InitializedNotification.type, {});
if (options.settings !== undefined) {
yield* notify(DidChangeConfigurationNotification.type, {
settings: options.settings as LSPAny,
});
}
yield* Effect.addFinalizer(() =>
Effect.tryPromise(() => connection.sendRequest(ShutdownRequest.type)).pipe(
Effect.timeout("3 seconds"),
Effect.andThen(
Effect.tryPromise(() =>
connection.sendNotification(ExitNotification.type),
),
),
Effect.andThen(Deferred.await(exited).pipe(Effect.timeout("2 seconds"))),
Effect.ignore,
),
);
const logs = Stream.merge( const logs = Stream.merge(
notifications(LogMessageNotification.type), notifications(LogMessageNotification.type),
notifications(ShowMessageNotification.type), notifications(ShowMessageNotification.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",
@@ -340,11 +562,11 @@ const section = (settings: unknown, path: string | undefined): LSPAny =>
(path === undefined (path === undefined
? settings ? settings
: path : path
.split(".") .split(".")
.reduce<unknown>( .reduce<unknown>(
(value, key) => (value as Record<string, unknown> | null)?.[key], (value, key) => (value as Record<string, unknown> | null)?.[key],
settings, settings,
)) as LSPAny; )) as LSPAny;
/** /**
* What we tell the server we can do. Deliberately narrow: no dynamic * What we tell the server we can do. Deliberately narrow: no dynamic
@@ -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
View File
@@ -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
View File
@@ -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/);
}),
);
});
}); });
+3 -1
View File
@@ -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,
+6 -2
View File
@@ -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,
}); });
}), }),
); );