diff --git a/packages/opencode/test/memory/helper.ts b/packages/opencode/test/memory/helper.ts index 53e6e0e3794..832c2c1d702 100644 --- a/packages/opencode/test/memory/helper.ts +++ b/packages/opencode/test/memory/helper.ts @@ -4,6 +4,31 @@ const MB = 1024 * 1024 export const PROJECT_ROOT = path.join(__dirname, "../..") +/** + * Check if a process is alive by sending signal 0. + * Returns false if the process has already exited. + */ +export function isAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +/** + * Send a signal to a process, ignoring errors (e.g. process already exited). + */ +function trySendSignal(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal) + } catch (err) { + // Expected when process already exited before signal delivery + console.log(` signal ${signal} to PID ${pid} failed (likely already exited): ${err}`) + } +} + /** * Force GC multiple times and return stable heap usage in MB. * Multiple passes + sleeps allow GC to finalize weak refs and sweep. @@ -79,14 +104,8 @@ export async function snapshotDescendants(rootPid: number): Promise> export async function assertNoOrphans(before: Set, after: Set): Promise { const orphans = new Set() for (const pid of after) { - if (!before.has(pid)) { - // Verify the process is still running - try { - process.kill(pid, 0) - orphans.add(pid) - } catch { - // Process already exited - } + if (!before.has(pid) && isAlive(pid)) { + orphans.add(pid) } } @@ -113,16 +132,10 @@ export async function assertNoOrphans(before: Set, after: Set): // Force-kill orphans to prevent cascading test failures for (const pid of orphans) { - try { - process.kill(pid, "SIGKILL") - } catch { - // Already exited - } + trySendSignal(pid, "SIGKILL") } - throw new Error( - `Found ${orphans.size} orphan process(es):\n${details.map((d) => ` ${d}`).join("\n")}`, - ) + throw new Error(`Found ${orphans.size} orphan process(es):\n${details.map((d) => ` ${d}`).join("\n")}`) } /** @@ -135,9 +148,7 @@ export async function waitForExit(pids: number[], timeoutMs = 3000): Promise 0 && Date.now() - start < timeoutMs) { for (const pid of remaining) { - try { - process.kill(pid, 0) - } catch { + if (!isAlive(pid)) { remaining.delete(pid) } } @@ -154,10 +165,6 @@ export async function waitForExit(pids: number[], timeoutMs = 3000): Promise | number[]): void { for (const pid of pids) { - try { - process.kill(pid, "SIGKILL") - } catch { - // Already exited - } + trySendSignal(pid, "SIGKILL") } } diff --git a/packages/opencode/test/memory/lsp-lifecycle.test.ts b/packages/opencode/test/memory/lsp-lifecycle.test.ts index 756d07cb73c..3a40af90f51 100644 --- a/packages/opencode/test/memory/lsp-lifecycle.test.ts +++ b/packages/opencode/test/memory/lsp-lifecycle.test.ts @@ -4,13 +4,7 @@ import { Instance } from "../../src/project/instance" import { LSPClient } from "../../src/lsp/client" import { spawn } from "child_process" import { tmpdir } from "../fixture/fixture" -import { - PROJECT_ROOT, - snapshotDescendants, - assertNoOrphans, - forceKillAll, - stableHeapMB, -} from "./helper" +import { PROJECT_ROOT, snapshotDescendants, assertNoOrphans, forceKillAll, stableHeapMB, isAlive } from "./helper" const FAKE_LSP_SERVER = path.join(PROJECT_ROOT, "test/fixture/lsp/fake-lsp-server.js") @@ -37,129 +31,104 @@ describe("memory: LSP lifecycle", () => { } }) - test( - "LSPClient.shutdown kills server process", - async () => { - await using tmp = await tmpdir({ git: true }) + test("LSPClient.shutdown kills server process", async () => { + await using tmp = await tmpdir({ git: true }) - beforePids = await snapshotDescendants(process.pid) + beforePids = await snapshotDescendants(process.pid) - await Instance.provide({ - directory: tmp.path, - fn: async () => { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const serverProcess = spawnLSP(tmp.path) + const pid = serverProcess.pid! + + const client = await LSPClient.create({ + serverID: "test-lsp", + server: { process: serverProcess as any }, + root: tmp.path, + }) + expect(client).toBeTruthy() + + // Verify process is running + expect(isAlive(pid)).toBe(true) + + // Shutdown + await client!.shutdown() + await Bun.sleep(300) + + // Verify process is gone + expect(isAlive(pid)).toBe(false) + + await Instance.dispose() + }, + }) + }, 30_000) + + test("Multiple create/shutdown cycles don't leak processes", async () => { + await using tmp = await tmpdir({ git: true }) + + beforePids = await snapshotDescendants(process.pid) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + for (let i = 0; i < 5; i++) { const serverProcess = spawnLSP(tmp.path) - const pid = serverProcess.pid! - const client = await LSPClient.create({ - serverID: "test-lsp", + serverID: `test-lsp-${i}`, server: { process: serverProcess as any }, root: tmp.path, }) - expect(client).toBeTruthy() - - // Verify process is running - let alive = true - try { - process.kill(pid, 0) - } catch { - alive = false - } - expect(alive).toBe(true) - - // Shutdown await client!.shutdown() - await Bun.sleep(300) + await Bun.sleep(100) + } - // Verify process is gone - let stillAlive = false - try { - process.kill(pid, 0) - stillAlive = true - } catch { - // Expected - } - expect(stillAlive).toBe(false) + await Bun.sleep(300) + const afterPids = await snapshotDescendants(process.pid) + await assertNoOrphans(beforePids, afterPids) - await Instance.dispose() - }, - }) - }, - 30_000, - ) + await Instance.dispose() + }, + }) + }, 60_000) - test( - "Multiple create/shutdown cycles don't leak processes", - async () => { - await using tmp = await tmpdir({ git: true }) + test("LSP create/shutdown doesn't leak memory", async () => { + await using tmp = await tmpdir({ git: true }) - beforePids = await snapshotDescendants(process.pid) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Warm-up + const warmProc = spawnLSP(tmp.path) + const warmClient = await LSPClient.create({ + serverID: "test-lsp-warm", + server: { process: warmProc as any }, + root: tmp.path, + }) + await warmClient!.shutdown() + await Bun.sleep(100) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - for (let i = 0; i < 5; i++) { - const serverProcess = spawnLSP(tmp.path) - const client = await LSPClient.create({ - serverID: `test-lsp-${i}`, - server: { process: serverProcess as any }, - root: tmp.path, - }) - await client!.shutdown() - await Bun.sleep(100) - } + const baseline = await stableHeapMB() - await Bun.sleep(300) - const afterPids = await snapshotDescendants(process.pid) - await assertNoOrphans(beforePids, afterPids) - - await Instance.dispose() - }, - }) - }, - 60_000, - ) - - test( - "LSP create/shutdown doesn't leak memory", - async () => { - await using tmp = await tmpdir({ git: true }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Warm-up - const warmProc = spawnLSP(tmp.path) - const warmClient = await LSPClient.create({ - serverID: "test-lsp-warm", - server: { process: warmProc as any }, + for (let i = 0; i < 10; i++) { + const serverProcess = spawnLSP(tmp.path) + const client = await LSPClient.create({ + serverID: `test-lsp-mem-${i}`, + server: { process: serverProcess as any }, root: tmp.path, }) - await warmClient!.shutdown() - await Bun.sleep(100) + await client!.shutdown() + await Bun.sleep(50) + } - const baseline = await stableHeapMB() + const after = await stableHeapMB() + const growth = after - baseline - for (let i = 0; i < 10; i++) { - const serverProcess = spawnLSP(tmp.path) - const client = await LSPClient.create({ - serverID: `test-lsp-mem-${i}`, - server: { process: serverProcess as any }, - root: tmp.path, - }) - await client!.shutdown() - await Bun.sleep(50) - } + console.log(` LSP create/shutdown 10x growth: ${growth.toFixed(2)} MB`) + expect(growth).toBeLessThan(5) - const after = await stableHeapMB() - const growth = after - baseline - - console.log(` LSP create/shutdown 10x growth: ${growth.toFixed(2)} MB`) - expect(growth).toBeLessThan(5) - - await Instance.dispose() - }, - }) - }, - 60_000, - ) + await Instance.dispose() + }, + }) + }, 60_000) })