Files
moltbot/src/infra/gateway-lock.test.ts
2026-02-23 05:45:54 +00:00

301 lines
8.4 KiB
TypeScript

import { createHash } from "node:crypto";
import { EventEmitter } from "node:events";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveConfigPath, resolveGatewayLockDir, resolveStateDir } from "../config/paths.js";
import { acquireGatewayLock, GatewayLockError, type GatewayLockOptions } from "./gateway-lock.js";
let fixtureRoot = "";
let fixtureCount = 0;
async function makeEnv() {
const dir = path.join(fixtureRoot, `case-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
const configPath = path.join(dir, "openclaw.json");
await fs.writeFile(configPath, "{}", "utf8");
await fs.mkdir(resolveGatewayLockDir(), { recursive: true });
return {
...process.env,
OPENCLAW_STATE_DIR: dir,
OPENCLAW_CONFIG_PATH: configPath,
};
}
async function acquireForTest(
env: NodeJS.ProcessEnv,
opts: Omit<GatewayLockOptions, "env" | "allowInTests"> = {},
) {
return await acquireGatewayLock({
env,
allowInTests: true,
timeoutMs: 30,
pollIntervalMs: 2,
...opts,
});
}
function resolveLockPath(env: NodeJS.ProcessEnv) {
const stateDir = resolveStateDir(env);
const configPath = resolveConfigPath(env, stateDir);
const hash = createHash("sha256").update(configPath).digest("hex").slice(0, 8);
const lockDir = resolveGatewayLockDir();
return { lockPath: path.join(lockDir, `gateway.${hash}.lock`), configPath };
}
function makeProcStat(pid: number, startTime: number) {
const fields = [
"R",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
String(startTime),
"1",
"1",
];
return `${pid} (node) ${fields.join(" ")}`;
}
function createLockPayload(params: { configPath: string; startTime: number; createdAt?: string }) {
return {
pid: process.pid,
createdAt: params.createdAt ?? new Date().toISOString(),
configPath: params.configPath,
startTime: params.startTime,
};
}
function mockProcStatRead(params: { onProcRead: () => string }) {
const readFileSync = fsSync.readFileSync;
return vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === `/proc/${process.pid}/stat`) {
return params.onProcRead();
}
return readFileSync(filePath as never, encoding as never) as never;
});
}
async function writeLockFile(
env: NodeJS.ProcessEnv,
params: { startTime: number; createdAt?: string } = { startTime: 111 },
) {
const { lockPath, configPath } = resolveLockPath(env);
const payload = createLockPayload({
configPath,
startTime: params.startTime,
createdAt: params.createdAt,
});
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
return { lockPath, configPath };
}
function createEaccesProcStatSpy() {
return mockProcStatRead({
onProcRead: () => {
throw new Error("EACCES");
},
});
}
function createPortProbeConnectionSpy(result: "connect" | "refused") {
return vi.spyOn(net, "createConnection").mockImplementation(() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = vi.fn();
setImmediate(() => {
if (result === "connect") {
socket.emit("connect");
return;
}
socket.emit("error", Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" }));
});
return socket;
});
}
async function writeRecentLockFile(env: NodeJS.ProcessEnv, startTime = 111) {
await writeLockFile(env, {
startTime,
createdAt: new Date().toISOString(),
});
}
describe("gateway lock", () => {
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-lock-"));
});
beforeEach(() => {
// Other suites occasionally leave global spies behind (Date.now, setTimeout, etc.).
// This test relies on fake timers advancing Date.now and setTimeout deterministically.
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
afterEach(() => {
vi.useRealTimers();
});
it("blocks concurrent acquisition until release", async () => {
// Fake timers can hang on Windows CI when combined with fs open loops.
// Keep this test on real timers and use small timeouts.
vi.useRealTimers();
const env = await makeEnv();
const lock = await acquireForTest(env, { timeoutMs: 50 });
expect(lock).not.toBeNull();
const pending = acquireForTest(env, { timeoutMs: 15 });
await expect(pending).rejects.toBeInstanceOf(GatewayLockError);
await lock?.release();
const lock2 = await acquireForTest(env);
await lock2?.release();
});
it("treats recycled linux pid as stale when start time mismatches", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z"));
const env = await makeEnv();
const { lockPath, configPath } = resolveLockPath(env);
const payload = createLockPayload({ configPath, startTime: 111 });
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
const statValue = makeProcStat(process.pid, 222);
const spy = mockProcStatRead({
onProcRead: () => statValue,
});
const lock = await acquireForTest(env, {
timeoutMs: 80,
pollIntervalMs: 5,
platform: "linux",
});
expect(lock).not.toBeNull();
await lock?.release();
spy.mockRestore();
});
it("keeps lock on linux when proc access fails unless stale", async () => {
vi.useRealTimers();
const env = await makeEnv();
await writeLockFile(env);
const spy = createEaccesProcStatSpy();
const pending = acquireForTest(env, {
timeoutMs: 15,
staleMs: 10_000,
platform: "linux",
});
await expect(pending).rejects.toBeInstanceOf(GatewayLockError);
spy.mockRestore();
});
it("keeps lock when fs.stat fails until payload is stale", async () => {
vi.useRealTimers();
const env = await makeEnv();
await writeLockFile(env);
const procSpy = createEaccesProcStatSpy();
const statSpy = vi
.spyOn(fs, "stat")
.mockRejectedValue(Object.assign(new Error("EPERM"), { code: "EPERM" }));
const pending = acquireForTest(env, {
timeoutMs: 20,
staleMs: 10_000,
platform: "linux",
});
await expect(pending).rejects.toBeInstanceOf(GatewayLockError);
procSpy.mockRestore();
statSpy.mockRestore();
});
it("treats lock as stale when owner pid is alive but configured port is free", async () => {
vi.useRealTimers();
const env = await makeEnv();
await writeRecentLockFile(env);
const connectSpy = createPortProbeConnectionSpy("refused");
const lock = await acquireForTest(env, {
timeoutMs: 80,
pollIntervalMs: 5,
staleMs: 10_000,
platform: "darwin",
port: 18789,
});
expect(lock).not.toBeNull();
await lock?.release();
connectSpy.mockRestore();
});
it("keeps lock when configured port is busy and owner pid is alive", async () => {
vi.useRealTimers();
const env = await makeEnv();
await writeRecentLockFile(env);
const connectSpy = createPortProbeConnectionSpy("connect");
try {
const pending = acquireForTest(env, {
timeoutMs: 20,
pollIntervalMs: 2,
staleMs: 10_000,
platform: "darwin",
port: 18789,
});
await expect(pending).rejects.toBeInstanceOf(GatewayLockError);
} finally {
connectSpy.mockRestore();
}
});
it("returns null when multi-gateway override is enabled", async () => {
const env = await makeEnv();
const lock = await acquireGatewayLock({
env: { ...env, OPENCLAW_ALLOW_MULTI_GATEWAY: "1", VITEST: "" },
});
expect(lock).toBeNull();
});
it("returns null in test env unless allowInTests is set", async () => {
const env = await makeEnv();
const lock = await acquireGatewayLock({
env: { ...env, VITEST: "1" },
});
expect(lock).toBeNull();
});
it("wraps unexpected fs errors as GatewayLockError", async () => {
const env = await makeEnv();
const openSpy = vi.spyOn(fs, "open").mockRejectedValueOnce(
Object.assign(new Error("denied"), {
code: "EACCES",
}),
);
await expect(acquireForTest(env)).rejects.toBeInstanceOf(GatewayLockError);
openSpy.mockRestore();
});
});