diff --git a/packages/cli/src/utils/runningCli.test.ts b/packages/cli/src/utils/runningCli.test.ts index f977069e1b7..166946f48d4 100644 --- a/packages/cli/src/utils/runningCli.test.ts +++ b/packages/cli/src/utils/runningCli.test.ts @@ -1,8 +1,18 @@ -import { existsSync, mkdtempSync, rmSync, unlinkSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + unlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { registerRunningCli } from "./runningCli.js"; +import { RUNNING_STALE_MS, registerRunningCli } from "./runningCli.js"; let dir = ""; afterEach(() => rmSync(dir, { recursive: true, force: true })); @@ -36,3 +46,38 @@ it("rewrites the file on each heartbeat, so a removed file comes back", async () process.off("exit", listener); } }); + +// A CLI killed by a signal never runs its exit listener, and the installer that sweeps such +// files never runs for an install layout it does not recognise, so each start sweeps too. +it("removes the files of processes that are gone or stopped beating, and keeps live ones", () => { + dir = mkdtempSync(join(tmpdir(), "hf-running-")); + const running = join(dir, "running"); + mkdirSync(running, { recursive: true }); + const exited = spawnSync(process.execPath, ["-e", ""]).pid; + const longAgo = new Date(Date.now() - RUNNING_STALE_MS - 60_000); + const dead = join(running, String(exited)); + const silent = join(running, String(process.ppid)); + const junk = join(running, "not-a-pid"); + writeFileSync(dead, ""); + writeFileSync(junk, ""); + writeFileSync(silent, ""); + utimesSync(silent, longAgo, longAgo); + + const before = process.listeners("exit"); + registerRunningCli(running); + + // Read the folder rather than test each path: an existence check followed by a write to the same + // path below is what CodeQL reports as a file system race. A listing also catches a stray file. + // A live pid whose file has not been touched for longer than the stale window is a reused pid. + expect(readdirSync(running)).toEqual([String(process.pid)]); + + // The same live pid with a fresh file is another CLI that is still beating. + writeFileSync(silent, ""); + registerRunningCli(running); + expect(readdirSync(running).sort()).toEqual([String(process.pid), String(process.ppid)].sort()); + + for (const listener of process.listeners("exit").filter((l) => !before.includes(l))) { + listener(0); + process.off("exit", listener); + } +}); diff --git a/packages/cli/src/utils/runningCli.ts b/packages/cli/src/utils/runningCli.ts index 538c955381d..3f875a79928 100644 --- a/packages/cli/src/utils/runningCli.ts +++ b/packages/cli/src/utils/runningCli.ts @@ -1,4 +1,4 @@ -import { mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -7,6 +7,38 @@ export const RUNNING_DIR = join(homedir(), ".hyperframes", "running"); const RUNNING_HEARTBEAT_MS = 30_000; export const RUNNING_STALE_MS = 10 * 60_000; +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM: the pid exists but belongs to another user. + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +} + +function pruneExitedClis(dir: string, own: string): void { + let names: string[] = []; + try { + names = readdirSync(dir); + } catch { + return; + } + for (const name of names) { + if (name === own) continue; + const file = join(dir, name); + try { + const pid = Number(name); + const beating = Date.now() - statSync(file).mtimeMs <= RUNNING_STALE_MS; + // A live pid whose file stopped beating is a reused pid, not a CLI. + if (Number.isInteger(pid) && pid > 0 && isAlive(pid) && beating) continue; + unlinkSync(file); + } catch { + /* another CLI removed it first */ + } + } +} + /** Mark this process as running until it exits. Never throws: a failed write only lets an update land sooner. */ export function registerRunningCli( dir: string = RUNNING_DIR, @@ -16,6 +48,7 @@ export function registerRunningCli( mkdirSync(dir, { recursive: true, mode: 0o700 }); const file = join(dir, String(process.pid)); writeFileSync(file, "", { mode: 0o600 }); + pruneExitedClis(dir, String(process.pid)); const heartbeat = setInterval(() => { try { mkdirSync(dir, { recursive: true, mode: 0o700 });