fix: replace empty catch blocks in memory test helpers with isAlive/trySendSignal utilities

This commit is contained in:
Alex Alecu
2026-02-25 15:39:45 +02:00
parent 5fe6b1eded
commit 7e38aaa18f
2 changed files with 113 additions and 137 deletions
+31 -24
View File
@@ -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<Set<number>>
export async function assertNoOrphans(before: Set<number>, after: Set<number>): Promise<void> {
const orphans = new Set<number>()
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<number>, after: Set<number>):
// 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<boo
while (remaining.size > 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<boo
*/
export function forceKillAll(pids: Set<number> | number[]): void {
for (const pid of pids) {
try {
process.kill(pid, "SIGKILL")
} catch {
// Already exited
}
trySendSignal(pid, "SIGKILL")
}
}
@@ -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)
})