Files
openclaw/src/shared/pid-alive.test.ts
Vincent Koc 5a2200b280 fix(sessions): harden recycled PID lock recovery follow-up (#31320)
* fix: detect PID recycling in session write lock staleness check

The session lock uses isPidAlive() to determine if a lock holder is
still running. In containers, PID recycling can cause a different
process to inherit the same PID, making the lock appear valid when
the original holder is dead.

Record the process start time (field 22 of /proc/pid/stat) in the
lock file and compare it during staleness checks. If the PID is alive
but its start time differs from the recorded value, the lock is
treated as stale and reclaimed immediately.

Backward compatible: lock files without starttime are handled with
the existing PID-alive + age-based logic. Non-Linux platforms skip
the starttime check entirely (getProcessStartTime returns null).

* shared: harden pid starttime parsing

* sessions: validate lock pid/starttime payloads

* changelog: note recycled PID lock recovery fix

* changelog: credit hiroki and vincent on lock recovery fix

---------

Co-authored-by: HirokiKobayashi-R <hiroki@rhems-japan.co.jp>
2026-03-01 21:42:22 -08:00

166 lines
6.0 KiB
TypeScript

import fsSync from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { getProcessStartTime, isPidAlive } from "./pid-alive.js";
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;
// Mock readFileSync to return zombie state for /proc/<pid>/status
const originalReadFileSync = fsSync.readFileSync;
vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === `/proc/${zombiePid}/status`) {
return `Name:\tnode\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t${zombiePid}\nPid:\t${zombiePid}\n`;
}
return originalReadFileSync(filePath as never, encoding as never) as never;
});
// Override platform to linux so the zombie check runs
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatformDescriptor) {
throw new Error("missing process.platform descriptor");
}
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: "linux",
});
try {
// Re-import the module so it picks up the mocked platform and fs
vi.resetModules();
const { isPidAlive: freshIsPidAlive } = await import("./pid-alive.js");
expect(freshIsPidAlive(zombiePid)).toBe(false);
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
});
});
describe("getProcessStartTime", () => {
it("returns a number on Linux for the current process", async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatformDescriptor) {
throw new Error("missing process.platform descriptor");
}
const originalReadFileSync = fsSync.readFileSync;
// Simulate a realistic /proc/<pid>/stat line
const fakeStat = `${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`;
vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === `/proc/${process.pid}/stat`) {
return fakeStat;
}
return originalReadFileSync(filePath as never, encoding as never) as never;
});
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: "linux",
});
try {
vi.resetModules();
const { getProcessStartTime: fresh } = await import("./pid-alive.js");
const starttime = fresh(process.pid);
expect(starttime).toBe(98765);
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
});
it("returns null on non-Linux platforms", () => {
if (process.platform === "linux") {
// On actual Linux, this test is trivially satisfied by the other tests.
expect(true).toBe(true);
return;
}
expect(getProcessStartTime(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();
});
it("returns null for malformed /proc stat content", async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatformDescriptor) {
throw new Error("missing process.platform descriptor");
}
const originalReadFileSync = fsSync.readFileSync;
vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === "/proc/42/stat") {
return "42 node S malformed";
}
return originalReadFileSync(filePath as never, encoding as never) as never;
});
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: "linux",
});
try {
vi.resetModules();
const { getProcessStartTime: fresh } = await import("./pid-alive.js");
expect(fresh(42)).toBeNull();
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
});
it("handles comm fields containing spaces and parentheses", async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatformDescriptor) {
throw new Error("missing process.platform descriptor");
}
const originalReadFileSync = fsSync.readFileSync;
// comm field with spaces and nested parens: "(My App (v2))"
const fakeStat = `42 (My App (v2)) S 1 42 42 0 -1 4194304 0 0 0 0 0 0 0 0 20 0 1 0 55555 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0`;
vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === "/proc/42/stat") {
return fakeStat;
}
return originalReadFileSync(filePath as never, encoding as never) as never;
});
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: "linux",
});
try {
vi.resetModules();
const { getProcessStartTime: fresh } = await import("./pid-alive.js");
expect(fresh(42)).toBe(55555);
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
vi.restoreAllMocks();
}
});
});