diff options
Diffstat (limited to 'packages/lsp/src/manager.test.ts')
| -rw-r--r-- | packages/lsp/src/manager.test.ts | 872 |
1 files changed, 436 insertions, 436 deletions
diff --git a/packages/lsp/src/manager.test.ts b/packages/lsp/src/manager.test.ts index d8cba4f..613ded6 100644 --- a/packages/lsp/src/manager.test.ts +++ b/packages/lsp/src/manager.test.ts @@ -4,449 +4,449 @@ import { encode } from "./framing.js"; import { LspManager } from "./manager.js"; function makeAutoHandshakeSpawn(): SpawnProcess { - return () => { - let messageHandler: ((data: Uint8Array) => void) | null = null; - - const proc: SpawnedProcess = { - stdin: { - write: (bytes: Uint8Array) => { - // Parse the message to handle initialize handshake - const decoded = new TextDecoder().decode(bytes); - const headerEnd = decoded.indexOf("\r\n\r\n"); - if (headerEnd === -1) return; - const json = decoded.slice(headerEnd + 4); - try { - const msg = JSON.parse(json); - if (msg.method === "initialize") { - // Send back initialize response - setTimeout(() => { - const response = JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { capabilities: {} }, - }); - messageHandler?.(encode(response)); - }, 5); - } - // Ignore other messages - } catch { - // ignore parse errors - } - }, - }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - messageHandler = cb; - }, - }, - pid: 12345, - kill: () => {}, - }; - return proc; - }; + return () => { + let messageHandler: ((data: Uint8Array) => void) | null = null; + + const proc: SpawnedProcess = { + stdin: { + write: (bytes: Uint8Array) => { + // Parse the message to handle initialize handshake + const decoded = new TextDecoder().decode(bytes); + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const json = decoded.slice(headerEnd + 4); + try { + const msg = JSON.parse(json); + if (msg.method === "initialize") { + // Send back initialize response + setTimeout(() => { + const response = JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { capabilities: {} }, + }); + messageHandler?.(encode(response)); + }, 5); + } + // Ignore other messages + } catch { + // ignore parse errors + } + }, + }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + messageHandler = cb; + }, + }, + pid: 12345, + kill: () => {}, + }; + return proc; + }; } function noopFileWatcher(): FileWatcher { - return () => ({ close: () => {} }); + return () => ({ close: () => {} }); } function fakeFs(files: Record<string, string> = {}): FsAccess { - return { - readText: async (path) => files[path] ?? "", - exists: async (path) => path in files, - }; + return { + readText: async (path) => files[path] ?? "", + exists: async (path) => path in files, + }; } describe("manager", () => { - it("status(cwd) lazy-spawns matching servers and reports connected", async () => { - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp", "--stdio"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - const statuses = await manager.status("/project"); - expect(statuses).toHaveLength(1); - expect(statuses[0]?.id).toBe("test"); - expect(statuses[0]?.state).toBe("connected"); - expect(statuses[0]?.root).toBe("/project"); - }, 10000); - - it("concurrent status for the same root spawns one process", async () => { - let spawnCount = 0; - const countingSpawn: SpawnProcess = () => { - spawnCount++; - return makeAutoHandshakeSpawn()([], { cwd: "/project" }); - }; - - const manager = new LspManager({ - spawn: countingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - const [s1, s2] = await Promise.all([manager.status("/project"), manager.status("/project")]); - - expect(s1).toHaveLength(1); - expect(s2).toHaveLength(1); - expect(spawnCount).toBe(1); - }, 10000); - - it("a failing spawn reports state:error and is not retried", async () => { - let spawnCount = 0; - const failingSpawn: SpawnProcess = () => { - spawnCount++; - throw new Error("spawn failed"); - }; - - const manager = new LspManager({ - spawn: failingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - const s1 = await manager.status("/project"); - expect(s1[0]?.state).toBe("error"); - expect(spawnCount).toBe(1); - - // Second call should not retry - const s2 = await manager.status("/project"); - expect(s2[0]?.state).toBe("error"); - expect(spawnCount).toBe(1); - }); - - it("shutdownAll kills all spawned processes (incl. sidecars)", async () => { - let killed = false; - const trackableSpawn: SpawnProcess = () => { - const proc = makeAutoHandshakeSpawn()([], { cwd: "/project" }); - return { - ...proc, - kill: () => { - killed = true; - }, - }; - }; - - const manager = new LspManager({ - spawn: trackableSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - await manager.status("/project"); - manager.shutdownAll(); - expect(killed).toBe(true); - }, 10000); - - it("resolves config per cwd (distinct cwds, opencode.json fallback)", async () => { - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/proj-a/.dispatch/lsp.json": JSON.stringify({ - servers: { a: { command: ["a-lsp"], extensions: [".a"], rootMarkers: [] } }, - }), - "/proj-b/opencode.json": JSON.stringify({ - lsp: { b: { command: ["b-lsp"], extensions: [".b"] } }, - }), - }), - }); - - const a = await manager.status("/proj-a"); - const b = await manager.status("/proj-b"); - expect(a.map((s) => s.id)).toEqual(["a"]); - expect(b.map((s) => s.id)).toEqual(["b"]); - }, 10000); - - it("manager: broken server recovers after config is fixed (no shutdownAll)", async () => { - const files: Record<string, string> = { - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["bad-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }; - - const spawn: SpawnProcess = (command, opts) => { - if (command[0] === "bad-lsp") throw new Error("spawn failed"); - return makeAutoHandshakeSpawn()(command, opts); - }; - - const manager = new LspManager({ - spawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs(files), - now: () => 0, - }); - - // Bad config → spawn fails → broken. - const s1 = await manager.status("/project"); - expect(s1[0]?.state).toBe("error"); - - // Fix the config: switch the command to a working one. NO shutdownAll. - files["/project/.dispatch/lsp.json"] = JSON.stringify({ - servers: { - test: { - command: ["good-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }); - - // Next status() re-reads config, detects the change, and re-spawns. - const s2 = await manager.status("/project"); - expect(s2[0]?.state).toBe("connected"); - }, 10000); - - it("manager: no retry storm — repeated status() with no config change does not re-spawn a broken server in a loop", async () => { - let spawnCount = 0; - const failingSpawn: SpawnProcess = () => { - spawnCount++; - throw new Error("spawn failed"); - }; - - const manager = new LspManager({ - spawn: failingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, - }), - }), - // Frozen clock: the bounded backoff never elapses, so a config-unchanged - // broken server is never retried in a loop. - now: () => 0, - }); - - await manager.status("/project"); - await manager.status("/project"); - await manager.status("/project"); - await manager.status("/project"); - - expect(spawnCount).toBe(1); - }); - - it("manager: configSource reaches status()", async () => { - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/d/.dispatch/lsp.json": JSON.stringify({ - servers: { d: { command: ["d-lsp"], extensions: [".d"], rootMarkers: [] } }, - }), - "/o/opencode.json": JSON.stringify({ - lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, - }), - "/b/tsconfig.json": "{}", - }), - now: () => 0, - }); - - const d = await manager.status("/d"); - expect(d[0]?.configSource).toBe(".dispatch/lsp.json"); - - const o = await manager.status("/o"); - expect(o[0]?.configSource).toBe("opencode.json"); - - const b = await manager.status("/b"); - expect(b[0]?.configSource).toBe("built-in"); - }, 10000); - - it("config: shadow warning logged when .dispatch/lsp.json present and opencode.json also has lsp", async () => { - const warns: Array<{ - msg: string; - attrs?: Record<string, string | number | boolean | null>; - }> = []; - - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { d: { command: ["d-lsp"], extensions: [".d"] } }, - }), - "/project/opencode.json": JSON.stringify({ - lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, - }), - }), - logger: { - info: () => {}, - warn: (msg, attrs) => warns.push({ msg, attrs }), - error: () => {}, - }, - now: () => 0, - }); - - await manager.status("/project"); - - const shadow = warns.find((w) => w.msg.includes("shadowing")); - expect(shadow).toBeDefined(); - expect(shadow?.attrs?.cwd).toBe("/project"); - - // A second status() over the SAME (still-shadowed) cwd does not re-warn. - const before = warns.length; - await manager.status("/project"); - expect(warns.length).toBe(before); - }, 10000); - - it("status: error string includes config source on spawn failure", async () => { - const failingSpawn: SpawnProcess = () => { - throw new Error("spawn failed"); - }; - - const manager = new LspManager({ - spawn: failingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, - }), - }), - now: () => 0, - }); - - const s = await manager.status("/project"); - expect(s[0]?.state).toBe("error"); - expect(s[0]?.configSource).toBe(".dispatch/lsp.json"); - expect(s[0]?.error).toContain("[from .dispatch/lsp.json]"); - expect(s[0]?.error).toContain("spawn failed"); - }); - - it("a client that dies after connecting is skipped + re-spawned after backoff (no storm, no eternal hang)", async () => { - // A spawn that completes the initialize handshake AND lets the test - // simulate process death via the captured onExit handler. - const exitHandlers: Array<(info: { code: number | null; signal?: string }) => void> = []; - let spawnCount = 0; - const spawn: SpawnProcess = () => { - spawnCount++; - let messageHandler: ((data: Uint8Array) => void) | null = null; - const proc: SpawnedProcess = { - stdin: { - write: (bytes: Uint8Array) => { - const decoded = new TextDecoder().decode(bytes); - const headerEnd = decoded.indexOf("\r\n\r\n"); - if (headerEnd === -1) return; - const json = decoded.slice(headerEnd + 4); - try { - const msg = JSON.parse(json); - if (msg.method === "initialize") { - setTimeout(() => { - const response = JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { capabilities: {} }, - }); - messageHandler?.(encode(response)); - }, 1); - } - } catch { - // ignore - } - }, - }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - messageHandler = cb; - }, - }, - pid: 1000 + spawnCount, - kill: () => {}, - onExit: (handler) => { - exitHandlers.push(handler); - }, - }; - return proc; - }; - - const clock = { now: 0 }; - const manager = new LspManager({ - spawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - steep: { - command: ["steep", "--stdio"], - extensions: [".rb"], - rootMarkers: [], - }, - }, - }), - }), - now: () => clock.now, - }); - - // 1) Connects. - const s1 = await manager.status("/project"); - expect(s1[0]?.state).toBe("connected"); - expect(spawnCount).toBe(1); - - // 2) Simulate the process dying (user kill / crash) via onExit. - exitHandlers[0]?.({ code: 1 }); - const clientAfterDeath = manager.getClient("steep", "/project"); - expect(clientAfterDeath?.getState()).toBe("error"); - - // 3) status() now reports error (and seeds a broken entry for backoff). - // Backoff not elapsed yet (clock frozen at 0) → NOT re-spawned. - const s2 = await manager.status("/project"); - expect(s2[0]?.state).toBe("error"); - expect(s2[0]?.error).toMatch(/process exited/); - expect(spawnCount).toBe(1); // no retry storm before backoff - - // 4) After the backoff elapses, status() re-spawns a fresh server. - clock.now = 31_000; - const s3 = await manager.status("/project"); - expect(s3[0]?.state).toBe("connected"); - expect(spawnCount).toBe(2); // re-spawned exactly once - }); + it("status(cwd) lazy-spawns matching servers and reports connected", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp", "--stdio"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const statuses = await manager.status("/project"); + expect(statuses).toHaveLength(1); + expect(statuses[0]?.id).toBe("test"); + expect(statuses[0]?.state).toBe("connected"); + expect(statuses[0]?.root).toBe("/project"); + }, 10000); + + it("concurrent status for the same root spawns one process", async () => { + let spawnCount = 0; + const countingSpawn: SpawnProcess = () => { + spawnCount++; + return makeAutoHandshakeSpawn()([], { cwd: "/project" }); + }; + + const manager = new LspManager({ + spawn: countingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const [s1, s2] = await Promise.all([manager.status("/project"), manager.status("/project")]); + + expect(s1).toHaveLength(1); + expect(s2).toHaveLength(1); + expect(spawnCount).toBe(1); + }, 10000); + + it("a failing spawn reports state:error and is not retried", async () => { + let spawnCount = 0; + const failingSpawn: SpawnProcess = () => { + spawnCount++; + throw new Error("spawn failed"); + }; + + const manager = new LspManager({ + spawn: failingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const s1 = await manager.status("/project"); + expect(s1[0]?.state).toBe("error"); + expect(spawnCount).toBe(1); + + // Second call should not retry + const s2 = await manager.status("/project"); + expect(s2[0]?.state).toBe("error"); + expect(spawnCount).toBe(1); + }); + + it("shutdownAll kills all spawned processes (incl. sidecars)", async () => { + let killed = false; + const trackableSpawn: SpawnProcess = () => { + const proc = makeAutoHandshakeSpawn()([], { cwd: "/project" }); + return { + ...proc, + kill: () => { + killed = true; + }, + }; + }; + + const manager = new LspManager({ + spawn: trackableSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + await manager.status("/project"); + manager.shutdownAll(); + expect(killed).toBe(true); + }, 10000); + + it("resolves config per cwd (distinct cwds, opencode.json fallback)", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/proj-a/.dispatch/lsp.json": JSON.stringify({ + servers: { a: { command: ["a-lsp"], extensions: [".a"], rootMarkers: [] } }, + }), + "/proj-b/opencode.json": JSON.stringify({ + lsp: { b: { command: ["b-lsp"], extensions: [".b"] } }, + }), + }), + }); + + const a = await manager.status("/proj-a"); + const b = await manager.status("/proj-b"); + expect(a.map((s) => s.id)).toEqual(["a"]); + expect(b.map((s) => s.id)).toEqual(["b"]); + }, 10000); + + it("manager: broken server recovers after config is fixed (no shutdownAll)", async () => { + const files: Record<string, string> = { + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["bad-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }; + + const spawn: SpawnProcess = (command, opts) => { + if (command[0] === "bad-lsp") throw new Error("spawn failed"); + return makeAutoHandshakeSpawn()(command, opts); + }; + + const manager = new LspManager({ + spawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs(files), + now: () => 0, + }); + + // Bad config → spawn fails → broken. + const s1 = await manager.status("/project"); + expect(s1[0]?.state).toBe("error"); + + // Fix the config: switch the command to a working one. NO shutdownAll. + files["/project/.dispatch/lsp.json"] = JSON.stringify({ + servers: { + test: { + command: ["good-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }); + + // Next status() re-reads config, detects the change, and re-spawns. + const s2 = await manager.status("/project"); + expect(s2[0]?.state).toBe("connected"); + }, 10000); + + it("manager: no retry storm — repeated status() with no config change does not re-spawn a broken server in a loop", async () => { + let spawnCount = 0; + const failingSpawn: SpawnProcess = () => { + spawnCount++; + throw new Error("spawn failed"); + }; + + const manager = new LspManager({ + spawn: failingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, + }), + }), + // Frozen clock: the bounded backoff never elapses, so a config-unchanged + // broken server is never retried in a loop. + now: () => 0, + }); + + await manager.status("/project"); + await manager.status("/project"); + await manager.status("/project"); + await manager.status("/project"); + + expect(spawnCount).toBe(1); + }); + + it("manager: configSource reaches status()", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/d/.dispatch/lsp.json": JSON.stringify({ + servers: { d: { command: ["d-lsp"], extensions: [".d"], rootMarkers: [] } }, + }), + "/o/opencode.json": JSON.stringify({ + lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, + }), + "/b/tsconfig.json": "{}", + }), + now: () => 0, + }); + + const d = await manager.status("/d"); + expect(d[0]?.configSource).toBe(".dispatch/lsp.json"); + + const o = await manager.status("/o"); + expect(o[0]?.configSource).toBe("opencode.json"); + + const b = await manager.status("/b"); + expect(b[0]?.configSource).toBe("built-in"); + }, 10000); + + it("config: shadow warning logged when .dispatch/lsp.json present and opencode.json also has lsp", async () => { + const warns: Array<{ + msg: string; + attrs?: Record<string, string | number | boolean | null>; + }> = []; + + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { d: { command: ["d-lsp"], extensions: [".d"] } }, + }), + "/project/opencode.json": JSON.stringify({ + lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, + }), + }), + logger: { + info: () => {}, + warn: (msg, attrs) => warns.push({ msg, attrs }), + error: () => {}, + }, + now: () => 0, + }); + + await manager.status("/project"); + + const shadow = warns.find((w) => w.msg.includes("shadowing")); + expect(shadow).toBeDefined(); + expect(shadow?.attrs?.cwd).toBe("/project"); + + // A second status() over the SAME (still-shadowed) cwd does not re-warn. + const before = warns.length; + await manager.status("/project"); + expect(warns.length).toBe(before); + }, 10000); + + it("status: error string includes config source on spawn failure", async () => { + const failingSpawn: SpawnProcess = () => { + throw new Error("spawn failed"); + }; + + const manager = new LspManager({ + spawn: failingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, + }), + }), + now: () => 0, + }); + + const s = await manager.status("/project"); + expect(s[0]?.state).toBe("error"); + expect(s[0]?.configSource).toBe(".dispatch/lsp.json"); + expect(s[0]?.error).toContain("[from .dispatch/lsp.json]"); + expect(s[0]?.error).toContain("spawn failed"); + }); + + it("a client that dies after connecting is skipped + re-spawned after backoff (no storm, no eternal hang)", async () => { + // A spawn that completes the initialize handshake AND lets the test + // simulate process death via the captured onExit handler. + const exitHandlers: Array<(info: { code: number | null; signal?: string }) => void> = []; + let spawnCount = 0; + const spawn: SpawnProcess = () => { + spawnCount++; + let messageHandler: ((data: Uint8Array) => void) | null = null; + const proc: SpawnedProcess = { + stdin: { + write: (bytes: Uint8Array) => { + const decoded = new TextDecoder().decode(bytes); + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const json = decoded.slice(headerEnd + 4); + try { + const msg = JSON.parse(json); + if (msg.method === "initialize") { + setTimeout(() => { + const response = JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { capabilities: {} }, + }); + messageHandler?.(encode(response)); + }, 1); + } + } catch { + // ignore + } + }, + }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + messageHandler = cb; + }, + }, + pid: 1000 + spawnCount, + kill: () => {}, + onExit: (handler) => { + exitHandlers.push(handler); + }, + }; + return proc; + }; + + const clock = { now: 0 }; + const manager = new LspManager({ + spawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + steep: { + command: ["steep", "--stdio"], + extensions: [".rb"], + rootMarkers: [], + }, + }, + }), + }), + now: () => clock.now, + }); + + // 1) Connects. + const s1 = await manager.status("/project"); + expect(s1[0]?.state).toBe("connected"); + expect(spawnCount).toBe(1); + + // 2) Simulate the process dying (user kill / crash) via onExit. + exitHandlers[0]?.({ code: 1 }); + const clientAfterDeath = manager.getClient("steep", "/project"); + expect(clientAfterDeath?.getState()).toBe("error"); + + // 3) status() now reports error (and seeds a broken entry for backoff). + // Backoff not elapsed yet (clock frozen at 0) → NOT re-spawned. + const s2 = await manager.status("/project"); + expect(s2[0]?.state).toBe("error"); + expect(s2[0]?.error).toMatch(/process exited/); + expect(spawnCount).toBe(1); // no retry storm before backoff + + // 4) After the backoff elapses, status() re-spawns a fresh server. + clock.now = 31_000; + const s3 = await manager.status("/project"); + expect(s3[0]?.state).toBe("connected"); + expect(spawnCount).toBe(2); // re-spawned exactly once + }); }); |
