From b028ba8e031030897fa2eaa8420ee2e37737b2d3 Mon Sep 17 00:00:00 2001 From: EFLKumo <96906415+EFLKumo@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:03:28 +0800 Subject: [PATCH] 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. --- CLAUDE.md | 12 +- src/lsp/LanguageServer.ts | 503 +++++++++++++++++++++++++++---------- src/main.ts | 22 ++ test/Editor.test.ts | 38 ++- test/fixtures/server.ts | 4 +- test/fixtures/workspace.ts | 8 +- 6 files changed, 441 insertions(+), 146 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 653f88b..9ea16b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/main.ts CLI entry (Command/Flag/Argument), logs to stderr, NodeRuntime.runMain 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 Diagnostics.ts publishDiagnostics accumulation; pull (textDocument/diagnostic) or settled push per doc version 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). - 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). +- 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 @@ -56,5 +63,4 @@ verified against the fixture server and `typescript-language-server`. `goal.md` no longer authoritative; the code is. 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 -`LspError` until the MCP client restarts the process). +when a server insists on it. diff --git a/src/lsp/LanguageServer.ts b/src/lsp/LanguageServer.ts index 6cc05de..33d8bf6 100644 --- a/src/lsp/LanguageServer.ts +++ b/src/lsp/LanguageServer.ts @@ -5,12 +5,20 @@ * `request` waits for the server to become idle (no `$/progress` work in * flight) and retries transient `ContentModified` / `ServerCancelled` * 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 { PassThrough } from "node:stream"; import { NodeStream } from "@effect/platform-node"; import { + Cause, Context, Deferred, + Duration, Effect, FiberSet, HashSet, @@ -26,14 +34,22 @@ import { CancellationTokenSource, type ClientCapabilities, type CompletionItemKind, + type ConfigurationParams, ConfigurationRequest, createMessageConnection, DidChangeConfigurationNotification, + DidChangeTextDocumentNotification, + type DidChangeTextDocumentParams, + DidCloseTextDocumentNotification, + type DidCloseTextDocumentParams, + DidOpenTextDocumentNotification, + type DidOpenTextDocumentParams, ErrorCodes, ExitNotification, type HandlerResult, InitializedNotification, InitializeRequest, + type InitializeResult, LogMessageNotification, type LSPAny, LSPErrorCodes, @@ -48,12 +64,20 @@ import { StreamMessageReader, StreamMessageWriter, type SymbolKind, + type TextDocumentItem, UnregistrationRequest, WorkDoneProgressCreateRequest, WorkspaceFoldersRequest, } from "vscode-languageserver-protocol/node"; 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 { readonly command: string; readonly args: ReadonlyArray; @@ -62,18 +86,26 @@ export interface Options { readonly initializationOptions?: unknown; /** Served as `workspace/configuration` and pushed via `didChangeConfiguration`. */ 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 = new Set([ + LSPErrorCodes.ContentModified, + LSPErrorCodes.ServerCancelled, + ErrorCodes.ConnectionInactive, + ErrorCodes.PendingResponseRejected, + ErrorCodes.MessageWriteError, +]); + export class LspError extends Schema.TaggedError()("LspError", { method: Schema.String, message: Schema.String, code: Schema.optional(Schema.Int), }) { get retryable() { - return ( - this.code === LSPErrorCodes.ContentModified || - this.code === LSPErrorCodes.ServerCancelled - ); + return this.code !== undefined && transient.has(this.code); } } @@ -89,8 +121,8 @@ export class LanguageServer extends Context.Service< readonly root: string; readonly capabilities: ServerCapabilities; readonly serverInfo: - | { readonly name: string; readonly version?: string | undefined } - | undefined; + | { readonly name: string; readonly version?: string | undefined } + | undefined; readonly request: ( type: RequestType, params: P, @@ -105,7 +137,7 @@ export class LanguageServer extends Context.Service< type: RequestType, handler: (params: P) => Effect.Effect, ) => Effect.Effect; - /** `window/logMessage` and `window/showMessage` traffic. */ + /** `window/logMessage` and `window/showMessage` traffic, plus restart announcements. */ readonly logs: Stream.Stream; } >()("lsp-mcp/lsp/LanguageServer") { @@ -113,110 +145,271 @@ export class LanguageServer extends Context.Service< Layer.effect(LanguageServer, make(options)); } +/** One process and its connection; replaced on crash. */ +interface Session { + readonly initialized: InitializeResult; + readonly exited: Deferred.Deferred; + readonly send: ( + type: RequestType, + params: P, + ) => Effect.Effect; + readonly notify:

( + type: NotificationType

, + params: P, + ) => Effect.Effect; +} + const make = Effect.fn("LanguageServer.make")(function* (options: Options) { const run = yield* FiberSet.makeRuntime(); const runPromise = yield* FiberSet.makeRuntimePromise(); - const exited = yield* Deferred.make(); const events = yield* PubSub.unbounded<{ readonly method: string; readonly params: unknown; }>(); + const lifecycle = yield* PubSub.unbounded(); const progress = yield* SubscriptionRef.make( HashSet.empty(), ); - const exit = (message: string) => - Deferred.doneUnsafe( - exited, - Effect.fail(new LspError({ method: "connection", message })), - ); + const initialized = yield* Deferred.make(); + /** The live session, or the one being started; failed once the policy gives up. */ + let current = Deferred.makeUnsafe(); + 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(); + 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({ - 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 workspaceFolders = [ { uri: rootUri, name: options.root.split(/[\\/]/).pop() ?? options.root }, ]; - connection.onRequest(WorkspaceFoldersRequest.type, () => workspaceFolders); - connection.listen(); + const handlers = new Map< + string, + (params: unknown) => HandlerResult + >([ + [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), + 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(); + 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({ + 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 = (type: RequestType, params: P) => + Effect.callback((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 =

(type: NotificationType

, params: P) => + Effect.tryPromise({ + try: () => connection.sendNotification(type, params as never), + catch: (error) => failure(type.method, error), }); - const send = (type: RequestType, params: P) => - Effect.callback((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))); + yield* SubscriptionRef.set(progress, HashSet.empty()); + 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.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( Stream.filter(HashSet.isEmpty), @@ -226,8 +419,9 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) { ); const request = (type: RequestType, params: P) => - idle.pipe( - Effect.andThen(send(type, params)), + session.pipe( + Effect.tap(() => idle), + Effect.flatMap((session) => session.send(type, params)), Effect.retry({ while: (error) => error.retryable, schedule: Schedule.exponential("100 millis").pipe( @@ -237,10 +431,11 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) { ); const notify =

(type: NotificationType

, params: P) => - Effect.tryPromise({ - try: () => connection.sendNotification(type, params as never), - catch: (error) => failure(type.method, error), - }).pipe(Effect.catch((error) => Effect.logWarning(error.message))); + 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 =

(type: NotificationType

): Stream.Stream

=> Stream.fromPubSub(events).pipe( @@ -254,55 +449,26 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) { ) => Effect.acquireRelease( Effect.sync(() => - connection.onRequest( + handlers.set( type.method, - (params: P): HandlerResult => + (params) => runPromise( - handler(params).pipe( + handler(params as P).pipe( Effect.mapError((error) => error instanceof ResponseError ? error : new ResponseError( - ErrorCodes.InternalError, - error instanceof Error ? error.message : String(error), - ), + ErrorCodes.InternalError, + error instanceof Error ? error.message : String(error), + ), ), ), ) as Promise, ), ), - (disposable) => Effect.sync(() => disposable.dispose()), + () => Effect.sync(() => handlers.delete(type.method)), ).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( notifications(LogMessageNotification.type), notifications(ShowMessageNotification.type), @@ -313,12 +479,13 @@ const make = Effect.fn("LanguageServer.make")(function* (options: Options) { message, }), ), + Stream.merge(Stream.fromPubSub(lifecycle)), ); return LanguageServer.of({ root: options.root, - capabilities: initialized.capabilities, - serverInfo: initialized.serverInfo, + capabilities: first.capabilities, + serverInfo: first.serverInfo, request, notify, 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) => { + 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, + 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 = { 1: "error", 2: "warning", @@ -340,11 +562,11 @@ const section = (settings: unknown, path: string | undefined): LSPAny => (path === undefined ? settings : path - .split(".") - .reduce( - (value, key) => (value as Record | null)?.[key], - settings, - )) as LSPAny; + .split(".") + .reduce( + (value, key) => (value as Record | null)?.[key], + settings, + )) as LSPAny; /** * What we tell the server we can do. Deliberately narrow: no dynamic @@ -395,7 +617,10 @@ const clientCapabilities: ClientCapabilities = { deprecatedSupport: true, }, completionItemKind: { - valueSet: Array.from({ length: 25 }, (_, i) => (i + 1) as CompletionItemKind), + valueSet: Array.from( + { length: 25 }, + (_, i) => (i + 1) as CompletionItemKind, + ), }, contextSupport: true, }, diff --git a/src/main.ts b/src/main.ts index dafca71..ee41c7d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,14 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import * as Server from "./Server.ts"; 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( "lsp-mcp", @@ -58,6 +66,14 @@ const command = Command.make( ), 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( Argument.withDescription("Language server executable"), ), @@ -75,6 +91,7 @@ const command = Command.make( settings, languages, capacity, + restart, command, args, }) { @@ -89,6 +106,7 @@ const command = Command.make( root: path.resolve(root), initializationOptions: Option.getOrUndefined(initOptions), settings: Option.getOrUndefined(settings), + restart, }, documents: { capacity, languages }, }), @@ -111,6 +129,10 @@ const command = Command.make( command: `lsp-mcp --settings '{"gopls":{"staticcheck":true}}' -- gopls`, description: "Go with workspace settings", }, + { + command: "lsp-mcp --restart always -- clangd", + description: "Keep bringing the server back, however often it crashes", + }, ]), ); diff --git a/test/Editor.test.ts b/test/Editor.test.ts index caae794..4456116 100644 --- a/test/Editor.test.ts +++ b/test/Editor.test.ts @@ -1,7 +1,7 @@ import { NodeServices } from "@effect/platform-node"; import { assert, describe, it } from "@effect/vitest"; 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 { Documents } from "../src/lsp/Documents.ts"; import * as Editor from "../src/lsp/Editor.ts"; @@ -12,6 +12,7 @@ const State = new RequestType( "fixture/state", ); const Progress = new RequestType("fixture/progress"); +const Crash = new NotificationType("fixture/crash"); const TestLayer = Layer.mergeAll( 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/); + }), + ); + }); }); diff --git a/test/fixtures/server.ts b/test/fixtures/server.ts index a117a4f..e781855 100644 --- a/test/fixtures/server.ts +++ b/test/fixtures/server.ts @@ -2,7 +2,8 @@ * A miniature language server used by the tests. Words are symbols: hover * describes the word under the cursor, definition is its first occurrence, * 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 { createConnection, @@ -136,6 +137,7 @@ connection.onDocumentSymbol(({ textDocument }) => { ); }); +connection.onNotification("fixture/crash", () => process.exit(1)); connection.onRequest("fixture/state", () => ({ open: documents.all().length, closes, diff --git a/test/fixtures/workspace.ts b/test/fixtures/workspace.ts index 77a4fa0..2b5dd7f 100644 --- a/test/fixtures/workspace.ts +++ b/test/fixtures/workspace.ts @@ -1,11 +1,14 @@ import { fileURLToPath } from "node:url"; 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)); /** A temporary workspace with the given files, served by the fixture language server. */ -export const workspace = (files: Record) => +export const workspace = ( + files: Record, + options?: Pick, +) => Layer.unwrap( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -18,6 +21,7 @@ export const workspace = (files: Record) => command: process.execPath, args: [server], root, + ...options, }); }), );