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.
208 lines
6.7 KiB
TypeScript
208 lines
6.7 KiB
TypeScript
import { NodeServices } from "@effect/platform-node";
|
|
import { assert, describe, it } from "@effect/vitest";
|
|
import { Effect, FileSystem, Layer } from "effect";
|
|
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";
|
|
import { LanguageServer } from "../src/lsp/LanguageServer.ts";
|
|
import { workspace } from "./fixtures/workspace.ts";
|
|
|
|
const State = new RequestType<null, { open: number; closes: number }, void>(
|
|
"fixture/state",
|
|
);
|
|
const Progress = new RequestType<number, string, void>("fixture/progress");
|
|
const Crash = new NotificationType<null>("fixture/crash");
|
|
|
|
const TestLayer = Layer.mergeAll(
|
|
Documents.layer({ capacity: 2, languages: {} }),
|
|
Diagnostics.layer,
|
|
).pipe(
|
|
Layer.provideMerge(
|
|
workspace({
|
|
"a.ts": "const alpha = 1\nexport const beta = alpha + alpha\n",
|
|
"b.ts": "// TODO fix\n",
|
|
"c.ts": "",
|
|
}),
|
|
),
|
|
Layer.provideMerge(NodeServices.layer),
|
|
);
|
|
|
|
describe("Editor", () => {
|
|
it.layer(TestLayer, { excludeTestServices: true })(
|
|
"with the fixture server",
|
|
(it) => {
|
|
it.effect("addresses positions by 1-based line and symbol name", () =>
|
|
Effect.gen(function* () {
|
|
const byColumn = yield* Editor.hover({
|
|
file: "a.ts",
|
|
line: 2,
|
|
column: 22,
|
|
});
|
|
const bySymbol = yield* Editor.hover({
|
|
file: "a.ts",
|
|
line: 2,
|
|
symbol: "alpha",
|
|
});
|
|
assert.strictEqual(byColumn?.contents, "word **alpha**");
|
|
assert.strictEqual(bySymbol?.contents, "word **alpha**");
|
|
|
|
const refs = yield* Editor.references({
|
|
file: "a.ts",
|
|
line: 1,
|
|
symbol: "alpha",
|
|
});
|
|
assert.deepStrictEqual(
|
|
refs.map((r) => [r.line, r.column, r.preview]),
|
|
[
|
|
[1, 7, "const alpha = 1"],
|
|
[2, 21, "export const beta = alpha + alpha"],
|
|
[2, 29, "export const beta = alpha + alpha"],
|
|
],
|
|
);
|
|
}),
|
|
);
|
|
|
|
it.effect("sees edits made on disk without being told", () =>
|
|
Effect.gen(function* () {
|
|
const fs = yield* FileSystem.FileSystem;
|
|
const docs = yield* Documents;
|
|
const lsp = yield* LanguageServer;
|
|
const before = yield* Editor.diagnostics({ file: "b.ts" });
|
|
assert.deepStrictEqual(
|
|
before.map((d) => [d.severity, d.line]),
|
|
[["warning", 1]],
|
|
);
|
|
|
|
yield* fs.writeFileString(
|
|
docs.uri("b.ts").replace("file://", ""),
|
|
"// done\n// TODO more\n",
|
|
);
|
|
const after = yield* Editor.diagnostics({ file: "b.ts" });
|
|
assert.deepStrictEqual(
|
|
after.map((d) => [d.severity, d.line]),
|
|
[["warning", 2]],
|
|
);
|
|
assert.strictEqual((yield* lsp.request(State, null)).open, 2);
|
|
}),
|
|
);
|
|
|
|
it.effect("closes the least recently used document beyond capacity", () =>
|
|
Effect.gen(function* () {
|
|
const lsp = yield* LanguageServer;
|
|
const docs = yield* Documents;
|
|
yield* docs.open("a.ts");
|
|
yield* docs.open("b.ts");
|
|
yield* docs.open("c.ts");
|
|
yield* Effect.sleep("50 millis");
|
|
const state = yield* lsp.request(State, null);
|
|
assert.strictEqual(state.open, 2);
|
|
assert.isAtLeast(state.closes, 1);
|
|
assert.deepStrictEqual((yield* docs.opened).map(docs.file).sort(), [
|
|
"b.ts",
|
|
"c.ts",
|
|
]);
|
|
}),
|
|
);
|
|
|
|
it.effect("previews a rename, and applies it only when asked", () =>
|
|
Effect.gen(function* () {
|
|
const docs = yield* Documents;
|
|
const preview = yield* Editor.rename({
|
|
file: "a.ts",
|
|
line: 1,
|
|
symbol: "alpha",
|
|
newName: "first",
|
|
});
|
|
assert.isFalse(preview.applied);
|
|
assert.strictEqual(preview.files[0]?.edits.length, 3);
|
|
assert.include((yield* docs.open("a.ts")).getText(), "alpha");
|
|
|
|
const applied = yield* Editor.rename({
|
|
file: "a.ts",
|
|
line: 1,
|
|
symbol: "alpha",
|
|
newName: "first",
|
|
apply: true,
|
|
});
|
|
assert.isTrue(applied.applied);
|
|
const text = (yield* docs.peek(docs.uri("a.ts"))).getText();
|
|
assert.strictEqual(
|
|
text,
|
|
"const first = 1\nexport const beta = first + first\n",
|
|
);
|
|
assert.strictEqual(
|
|
(yield* Editor.hover({ file: "a.ts", line: 1, symbol: "first" }))
|
|
?.contents,
|
|
"word **first**",
|
|
);
|
|
}),
|
|
);
|
|
|
|
it.effect("waits for the server's background work before asking", () =>
|
|
Effect.gen(function* () {
|
|
const lsp = yield* LanguageServer;
|
|
yield* lsp.request(Progress, 300);
|
|
const started = Date.now();
|
|
const symbols = yield* Editor.documentSymbols({ file: "c.ts" });
|
|
assert.isAtLeast(Date.now() - started, 250);
|
|
assert.deepStrictEqual(symbols, []);
|
|
}),
|
|
);
|
|
|
|
it.effect("explains what the server cannot do", () =>
|
|
Effect.gen(function* () {
|
|
const error = yield* Effect.flip(
|
|
Editor.definition({
|
|
file: "a.ts",
|
|
line: 1,
|
|
symbol: "alpha",
|
|
kind: "implementation",
|
|
}),
|
|
);
|
|
assert.strictEqual(error._tag, "LspError");
|
|
assert.match(
|
|
error.message,
|
|
/does not support implementationProvider/,
|
|
);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
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/);
|
|
}),
|
|
);
|
|
});
|
|
});
|