Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions packages/cli/src/utils/runningCli.test.ts
Original file line number Diff line number Diff line change
@@ -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 }));
Expand Down Expand Up @@ -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, "");
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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);
}
});
35 changes: 34 additions & 1 deletion packages/cli/src/utils/runningCli.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. The installer measures staleness on its own monotonic clock on purpose (its comment says the clock "stops while the machine sleeps"), while this compares wall-clock Date.now() to the file's mtime. After a laptop sleeps for more than 10 minutes, a CLI started right on wake can delete the marker of a long-running preview that is still alive. Node fires the overdue heartbeat as soon as that process resumes, so the file comes back almost immediately and I would not change anything here. Mentioning it so the difference from the installer is a known choice rather than an accident.

// 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,
Expand All @@ -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 });
Expand Down
Loading