Files
openclaw/src/shared/pid-alive.test.ts
Alix-007 6bcf071120 fix(infra): bound macOS process start probes (#109064)
* fix(infra): bound macOS process start probes

* docs(changelog): note macOS process probe deadline

Co-authored-by: Alix-007 <li.long15@xydigit.com>

* test(infra): cover process probe timeout

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-16 11:36:07 -07:00

203 lines
7.3 KiB
TypeScript

// PID liveness tests cover process existence checks across platforms.
import childProcess from "node:child_process";
import fsSync from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withMockedPlatform } from "../test-utils/vitest-spies.js";
import {
getFileLockProcessStartTime,
getProcessStartTime,
isPidAlive,
isPidDefinitelyDead,
} from "./pid-alive.js";
afterEach(() => {
vi.restoreAllMocks();
});
function mockProcReads(entries: Record<string, string>) {
const originalReadFileSync = fsSync.readFileSync;
vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
const key = String(filePath);
if (Object.hasOwn(entries, key)) {
return entries[key] as never;
}
return originalReadFileSync(filePath as never, encoding as never) as never;
});
}
describe("isPidAlive", () => {
it("returns true for the current running process", () => {
expect(isPidAlive(process.pid)).toBe(true);
});
it("returns false for a non-existent PID", () => {
expect(isPidAlive(2 ** 30)).toBe(false);
});
it("returns false for invalid PIDs", () => {
expect(isPidAlive(0)).toBe(false);
expect(isPidAlive(-1)).toBe(false);
expect(isPidAlive(1.5)).toBe(false);
expect(isPidAlive(Number.NaN)).toBe(false);
expect(isPidAlive(Number.POSITIVE_INFINITY)).toBe(false);
});
it("returns false for zombie processes on Linux", async () => {
const zombiePid = process.pid;
mockProcReads({
[`/proc/${zombiePid}/status`]: `Name:\tnode\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t${zombiePid}\nPid:\t${zombiePid}\n`,
});
await withMockedPlatform("linux", async () => {
expect(isPidAlive(zombiePid)).toBe(false);
});
});
it("treats unreadable linux proc status as non-zombie when kill succeeds", async () => {
const readFileSyncSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation(() => {
throw new Error("no proc status");
});
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
await withMockedPlatform("linux", async () => {
expect(isPidAlive(42)).toBe(true);
});
expect(readFileSyncSpy).toHaveBeenCalledWith("/proc/42/status", "utf8");
expect(killSpy).toHaveBeenCalledWith(42, 0);
});
});
describe("isPidDefinitelyDead", () => {
it("returns true for invalid PIDs", () => {
expect(isPidDefinitelyDead(0)).toBe(true);
expect(isPidDefinitelyDead(-1)).toBe(true);
expect(isPidDefinitelyDead(1.5)).toBe(true);
expect(isPidDefinitelyDead(Number.NaN)).toBe(true);
expect(isPidDefinitelyDead(Number.POSITIVE_INFINITY)).toBe(true);
});
it("returns true when process probing reports ESRCH", () => {
const error = Object.assign(new Error("missing process"), { code: "ESRCH" });
vi.spyOn(process, "kill").mockImplementation(() => {
throw error;
});
expect(isPidDefinitelyDead(42)).toBe(true);
expect(process["kill"]).toHaveBeenCalledWith(42, 0);
});
it("returns false when process probing reports EPERM", () => {
const error = Object.assign(new Error("permission denied"), { code: "EPERM" });
vi.spyOn(process, "kill").mockImplementation(() => {
throw error;
});
expect(isPidDefinitelyDead(42)).toBe(false);
expect(process["kill"]).toHaveBeenCalledWith(42, 0);
});
it("returns true for zombie processes on Linux", async () => {
const zombiePid = process.pid;
vi.spyOn(process, "kill").mockImplementation(() => true);
mockProcReads({
[`/proc/${zombiePid}/status`]: `Name:\tnode\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t${zombiePid}\nPid:\t${zombiePid}\n`,
});
await withMockedPlatform("linux", async () => {
expect(isPidDefinitelyDead(zombiePid)).toBe(true);
});
});
it("returns false for live non-zombie processes", async () => {
const livePid = process.pid;
vi.spyOn(process, "kill").mockImplementation(() => true);
mockProcReads({
[`/proc/${livePid}/status`]: `Name:\tnode\nUmask:\t0022\nState:\tS (sleeping)\nTgid:\t${livePid}\nPid:\t${livePid}\n`,
});
await withMockedPlatform("linux", async () => {
expect(isPidDefinitelyDead(livePid)).toBe(false);
});
});
});
describe("process start times", () => {
it("parses linux /proc stat start times and rejects malformed variants", async () => {
const fakeStatPrefix = "42 (node) S 1 42 42 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 ";
const fakeStatSuffix =
" 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0";
mockProcReads({
[`/proc/${process.pid}/stat`]: `${process.pid} (node) S 1 ${process.pid} ${process.pid} 0 -1 4194304 12345 0 0 0 100 50 0 0 20 0 8 0 98765 123456789 5000 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`,
"/proc/42/stat": `${fakeStatPrefix}55555${fakeStatSuffix}`,
"/proc/43/stat": "43 node S malformed",
"/proc/44/stat": `44 (My App (v2)) S 1 44 44 0 -1 4194304 0 0 0 0 0 0 0 0 20 0 1 0 66666 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`,
"/proc/45/stat": `${fakeStatPrefix}-1${fakeStatSuffix}`,
"/proc/46/stat": `${fakeStatPrefix}1.5${fakeStatSuffix}`,
});
await withMockedPlatform("linux", async () => {
expect(getProcessStartTime(process.pid)).toBe(98765);
expect(getProcessStartTime(42)).toBe(55555);
expect(getProcessStartTime(43)).toBeNull();
expect(getProcessStartTime(44)).toBe(66666);
expect(getProcessStartTime(45)).toBeNull();
expect(getProcessStartTime(46)).toBeNull();
});
});
it("keeps the runtime-state helper Linux-only", () => {
return withMockedPlatform("darwin", async () => {
expect(getProcessStartTime(42)).toBeNull();
});
});
it("parses Darwin file-lock owner start times as epoch seconds", () => {
const execSpy = vi
.spyOn(childProcess, "execFileSync")
.mockReturnValue("Mon Jul 6 12:34:56 2026\n");
return withMockedPlatform("darwin", async () => {
expect(getFileLockProcessStartTime(42)).toBe(Date.UTC(2026, 6, 6, 12, 34, 56) / 1000);
expect(execSpy).toHaveBeenCalledWith(
"/bin/ps",
["-o", "lstart=", "-p", "42"],
expect.objectContaining({
encoding: "utf8",
env: expect.objectContaining({ LC_ALL: "C", TZ: "UTC" }),
timeout: 1000,
}),
);
});
});
it("fails conservatively when the Darwin file-lock start-time probe times out", () => {
vi.spyOn(childProcess, "execFileSync").mockImplementation(() => {
throw Object.assign(new Error("spawnSync /bin/ps ETIMEDOUT"), {
code: "ETIMEDOUT",
signal: "SIGTERM",
});
});
return withMockedPlatform("darwin", async () => {
expect(getFileLockProcessStartTime(42)).toBeNull();
});
});
it("returns null on unsupported platforms", () => {
return withMockedPlatform("win32", async () => {
expect(getProcessStartTime(process.pid)).toBeNull();
expect(getFileLockProcessStartTime(process.pid)).toBeNull();
});
});
it("returns null for invalid PIDs", () => {
expect(getProcessStartTime(0)).toBeNull();
expect(getProcessStartTime(-1)).toBeNull();
expect(getProcessStartTime(1.5)).toBeNull();
expect(getProcessStartTime(Number.NaN)).toBeNull();
expect(getProcessStartTime(Number.POSITIVE_INFINITY)).toBeNull();
expect(getFileLockProcessStartTime(0)).toBeNull();
});
});