summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src/manager.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 18:36:08 +0900
committerAdam Malczewski <[email protected]>2026-06-25 18:36:08 +0900
commitde022cee7ac66c95d7ed6a35d4e00f8e2d92cbbc (patch)
tree041dcb1017e544a405526443cb578baa974bec0e /packages/lsp/src/manager.test.ts
parentfc1c3a54c3075990ec0dd0f97901bd46fe142923 (diff)
parent649fc4f66f40f7743683546f81d3320e7394e597 (diff)
downloaddispatch-de022cee7ac66c95d7ed6a35d4e00f8e2d92cbbc.tar.gz
dispatch-de022cee7ac66c95d7ed6a35d4e00f8e2d92cbbc.zip
Merge branch 'dev' into feature/ssh-support
Brings dev's retry-with-backoff (the transient `provider-retry` AgentEvent the web frontend consumes) + the LSP-dead-server per-edit-hang fix into the SSH feature branch, alongside the SSH waves 0-5c. All code files auto-merged cleanly (run-turn.ts, orchestrator.ts, runtime.ts, wire/index.ts, tool-edit-file/extension.ts, run-turn.test.ts — both computerId threading and retry-with-backoff coexist). Only tasks.md conflicted (status section — orchestrator-resolved; both feature sections kept). Verified post-merge: tsc -b EXIT 0, biome clean (391 files), 1730 vitest pass +6 sshd-integration skipped (was 1690; +40 from dev's retry/LSP tests). Wire dist rebuilt so the FE can re-sync the pinned @dispatch/wire dep and pick up BOTH provider-retry AND the SSH Computer/defaultComputerId types. No merge or push (into dev or otherwise).
Diffstat (limited to 'packages/lsp/src/manager.test.ts')
-rw-r--r--packages/lsp/src/manager.test.ts88
1 files changed, 88 insertions, 0 deletions
diff --git a/packages/lsp/src/manager.test.ts b/packages/lsp/src/manager.test.ts
index 1649111..d8cba4f 100644
--- a/packages/lsp/src/manager.test.ts
+++ b/packages/lsp/src/manager.test.ts
@@ -361,4 +361,92 @@ describe("manager", () => {
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
+ });
});