mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 00:11:38 +00:00
* refactor(memory): move QMD coordination to SQLite * chore: keep release notes in PR body * chore: annotate lease SQLite primitive
317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
/**
|
|
* Tests plugin SDK file lock retry, stale lock, and cleanup behavior.
|
|
*/
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
acquireFileLock,
|
|
drainFileLockStateForTest,
|
|
FILE_LOCK_STALE_ERROR_CODE,
|
|
FILE_LOCK_TIMEOUT_ERROR_CODE,
|
|
reclaimDefinitelyStaleFileLock,
|
|
resetFileLockStateForTest,
|
|
} from "./file-lock.js";
|
|
|
|
describe("acquireFileLock", () => {
|
|
let tempDir = "";
|
|
|
|
beforeEach(async () => {
|
|
resetFileLockStateForTest();
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-file-lock-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await drainFileLockStateForTest();
|
|
vi.restoreAllMocks();
|
|
if (tempDir) {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("respects the configured retry budget even when stale windows are much larger", async () => {
|
|
const filePath = path.join(tempDir, "oauth-refresh");
|
|
const lockPath = `${filePath}.lock`;
|
|
const options = {
|
|
retries: {
|
|
retries: 1,
|
|
factor: 1,
|
|
minTimeout: 20,
|
|
maxTimeout: 20,
|
|
},
|
|
stale: 100,
|
|
} as const;
|
|
|
|
await fs.writeFile(
|
|
lockPath,
|
|
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
|
|
"utf8",
|
|
);
|
|
|
|
let caught: { code?: string; lockPath?: string } | undefined;
|
|
try {
|
|
await acquireFileLock(filePath, options);
|
|
} catch (error) {
|
|
caught = error as { code?: string; lockPath?: string };
|
|
}
|
|
expect(caught?.code).toBe(FILE_LOCK_TIMEOUT_ERROR_CODE);
|
|
expect(caught?.lockPath ? path.relative(await fs.realpath(tempDir), caught.lockPath) : "").toBe(
|
|
"oauth-refresh.lock",
|
|
);
|
|
}, 5_000);
|
|
|
|
it("reclaims a stale lock when its owner pid is dead", async () => {
|
|
const filePath = path.join(tempDir, "auth-profiles.json");
|
|
const lockPath = `${filePath}.lock`;
|
|
const options = {
|
|
retries: {
|
|
retries: 0,
|
|
factor: 1,
|
|
minTimeout: 1,
|
|
maxTimeout: 1,
|
|
},
|
|
stale: 10,
|
|
} as const;
|
|
|
|
const deadPid = 2 ** 30;
|
|
await fs.writeFile(
|
|
lockPath,
|
|
JSON.stringify({ pid: deadPid, createdAt: new Date(Date.now() - 60_000).toISOString() }),
|
|
"utf8",
|
|
);
|
|
|
|
const lock = await acquireFileLock(filePath, options);
|
|
await expect(fs.readFile(lockPath, "utf8")).resolves.toContain(`"pid": ${process.pid}`);
|
|
await lock.release();
|
|
});
|
|
|
|
it("fails closed for a security-sensitive stale lock", async () => {
|
|
const filePath = path.join(tempDir, "exec-approvals.json");
|
|
const lockPath = `${filePath}.lock`;
|
|
const deadPid = 2 ** 30;
|
|
await fs.writeFile(
|
|
lockPath,
|
|
JSON.stringify({ pid: deadPid, createdAt: new Date(Date.now() - 60_000).toISOString() }),
|
|
"utf8",
|
|
);
|
|
|
|
await expect(
|
|
acquireFileLock(filePath, {
|
|
retries: { retries: 0, factor: 1, minTimeout: 1, maxTimeout: 1 },
|
|
stale: 10,
|
|
staleRecovery: "fail-closed",
|
|
}),
|
|
).rejects.toMatchObject({ code: FILE_LOCK_STALE_ERROR_CODE });
|
|
await expect(fs.readFile(lockPath, "utf8")).resolves.toContain(`"pid":${deadPid}`);
|
|
});
|
|
|
|
it("keeps a fresh lock when its payload is not readable", async () => {
|
|
const filePath = path.join(tempDir, "payload-pending");
|
|
const lockPath = `${filePath}.lock`;
|
|
const options = {
|
|
retries: {
|
|
retries: 0,
|
|
factor: 1,
|
|
minTimeout: 1,
|
|
maxTimeout: 1,
|
|
},
|
|
stale: 60_000,
|
|
} as const;
|
|
|
|
await fs.writeFile(lockPath, "{", "utf8");
|
|
|
|
let caught: { lockPath?: string } | undefined;
|
|
await expect(
|
|
(async () => {
|
|
try {
|
|
await acquireFileLock(filePath, options);
|
|
} catch (err) {
|
|
caught = err as { lockPath?: string };
|
|
throw err;
|
|
}
|
|
})(),
|
|
).rejects.toMatchObject({
|
|
code: FILE_LOCK_TIMEOUT_ERROR_CODE,
|
|
});
|
|
await expect(fs.realpath(caught?.lockPath ?? "")).resolves.toBe(await fs.realpath(lockPath));
|
|
await expect(fs.readFile(lockPath, "utf8")).resolves.toBe("{");
|
|
});
|
|
|
|
it("does not unlink an ownerless sidecar while its creator still holds it", async () => {
|
|
const filePath = path.join(tempDir, "payload-pending-open");
|
|
const lockPath = `${filePath}.lock`;
|
|
const owner = await fs.open(lockPath, "wx");
|
|
try {
|
|
const staleAt = new Date(Date.now() - 60_000);
|
|
await owner.utimes(staleAt, staleAt);
|
|
const before = await owner.stat();
|
|
|
|
await expect(
|
|
acquireFileLock(filePath, {
|
|
retries: { retries: 0, factor: 1, minTimeout: 1, maxTimeout: 1 },
|
|
stale: 10,
|
|
}),
|
|
).rejects.toMatchObject({ code: FILE_LOCK_TIMEOUT_ERROR_CODE });
|
|
|
|
const after = await fs.stat(lockPath);
|
|
expect({ dev: after.dev, ino: after.ino }).toEqual({ dev: before.dev, ino: before.ino });
|
|
await owner.writeFile(`${JSON.stringify({ pid: process.pid })}\n`, "utf8");
|
|
await expect(fs.readFile(lockPath, "utf8")).resolves.toContain(`"pid":${process.pid}`);
|
|
} finally {
|
|
await owner.close();
|
|
await fs.rm(lockPath, { force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps an expired malformed lock because ownership cannot be proven", async () => {
|
|
const filePath = path.join(tempDir, "payload-crashed");
|
|
const lockPath = `${filePath}.lock`;
|
|
const options = {
|
|
retries: {
|
|
retries: 0,
|
|
factor: 1,
|
|
minTimeout: 1,
|
|
maxTimeout: 1,
|
|
},
|
|
stale: 10,
|
|
} as const;
|
|
|
|
await fs.writeFile(lockPath, "{", "utf8");
|
|
const staleAt = new Date(Date.now() - 60_000);
|
|
await fs.utimes(lockPath, staleAt, staleAt);
|
|
|
|
await expect(acquireFileLock(filePath, options)).rejects.toMatchObject({
|
|
code: FILE_LOCK_TIMEOUT_ERROR_CODE,
|
|
});
|
|
await expect(fs.readFile(lockPath, "utf8")).resolves.toBe("{");
|
|
});
|
|
|
|
it("keeps an expired lock when its live owner has no starttime proof", async () => {
|
|
const filePath = path.join(tempDir, "live-owner");
|
|
const lockPath = `${filePath}.lock`;
|
|
const options = {
|
|
retries: {
|
|
retries: 0,
|
|
factor: 1,
|
|
minTimeout: 1,
|
|
maxTimeout: 1,
|
|
},
|
|
stale: 10,
|
|
} as const;
|
|
|
|
await fs.writeFile(
|
|
lockPath,
|
|
JSON.stringify({ pid: process.pid, createdAt: new Date(Date.now() - 60_000).toISOString() }),
|
|
"utf8",
|
|
);
|
|
|
|
let caught: { lockPath?: string } | undefined;
|
|
await expect(
|
|
(async () => {
|
|
try {
|
|
await acquireFileLock(filePath, options);
|
|
} catch (err) {
|
|
caught = err as { lockPath?: string };
|
|
throw err;
|
|
}
|
|
})(),
|
|
).rejects.toMatchObject({
|
|
code: FILE_LOCK_TIMEOUT_ERROR_CODE,
|
|
});
|
|
await expect(fs.realpath(caught?.lockPath ?? "")).resolves.toBe(await fs.realpath(lockPath));
|
|
await expect(fs.readFile(lockPath, "utf8")).resolves.toContain(`"pid":${process.pid}`);
|
|
});
|
|
|
|
it("closes an opened lock handle when writing the owner payload fails", async () => {
|
|
const filePath = path.join(tempDir, "write-fails");
|
|
const writeError = new Error("owner write failed");
|
|
const close = vi.fn().mockResolvedValue(undefined);
|
|
vi.spyOn(fs, "open").mockResolvedValue({
|
|
close,
|
|
writeFile: vi.fn().mockRejectedValue(writeError),
|
|
} as unknown as Awaited<ReturnType<typeof fs.open>>);
|
|
|
|
await expect(
|
|
acquireFileLock(filePath, {
|
|
retries: {
|
|
retries: 0,
|
|
factor: 1,
|
|
minTimeout: 1,
|
|
maxTimeout: 1,
|
|
},
|
|
stale: 100,
|
|
}),
|
|
).rejects.toThrow(writeError);
|
|
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reclaims a definitely stale retired lock sidecar", async () => {
|
|
const lockPath = path.join(tempDir, "embed.lock.lock");
|
|
await fs.writeFile(
|
|
lockPath,
|
|
`${JSON.stringify({ pid: 2 ** 30, createdAt: new Date().toISOString() })}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
await expect(reclaimDefinitelyStaleFileLock(lockPath)).resolves.toBe("removed");
|
|
await expect(fs.access(lockPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(reclaimDefinitelyStaleFileLock(lockPath)).resolves.toBe("missing");
|
|
});
|
|
|
|
it("retains live, malformed, and symlink lock sidecars", async () => {
|
|
const livePath = path.join(tempDir, "live.lock.lock");
|
|
const malformedPath = path.join(tempDir, "malformed.lock.lock");
|
|
const staleTargetPath = path.join(tempDir, "stale-target.lock");
|
|
const symlinkPath = path.join(tempDir, "symlink.lock.lock");
|
|
await fs.writeFile(
|
|
livePath,
|
|
`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(malformedPath, "{", "utf8");
|
|
await fs.writeFile(
|
|
staleTargetPath,
|
|
`${JSON.stringify({ pid: 2 ** 30, createdAt: new Date().toISOString() })}\n`,
|
|
"utf8",
|
|
);
|
|
await fs.symlink(staleTargetPath, symlinkPath);
|
|
|
|
await expect(reclaimDefinitelyStaleFileLock(livePath)).resolves.toBe("retained");
|
|
await expect(reclaimDefinitelyStaleFileLock(malformedPath)).resolves.toBe("retained");
|
|
await expect(reclaimDefinitelyStaleFileLock(symlinkPath)).resolves.toBe("retained");
|
|
await expect(fs.readFile(livePath, "utf8")).resolves.toContain(`"pid":${process.pid}`);
|
|
await expect(fs.readFile(malformedPath, "utf8")).resolves.toBe("{");
|
|
await expect(fs.lstat(symlinkPath)).resolves.toMatchObject({ mode: expect.any(Number) });
|
|
expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true);
|
|
await expect(fs.readFile(staleTargetPath, "utf8")).resolves.toContain(`"pid":${2 ** 30}`);
|
|
});
|
|
|
|
it("retains a stale snapshot replaced before reclaim approval", async () => {
|
|
const lockPath = path.join(tempDir, "replaced.lock.lock");
|
|
await fs.writeFile(
|
|
lockPath,
|
|
`${JSON.stringify({ pid: 2 ** 30, createdAt: new Date().toISOString() })}\n`,
|
|
"utf8",
|
|
);
|
|
const originalLstat = fs.lstat.bind(fs);
|
|
let lockLstatCalls = 0;
|
|
vi.spyOn(fs, "lstat").mockImplementation(async (...args) => {
|
|
const [candidate] = args;
|
|
if (candidate === lockPath && ++lockLstatCalls === 3) {
|
|
await fs.rm(lockPath);
|
|
await fs.writeFile(
|
|
lockPath,
|
|
`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
return await originalLstat(...args);
|
|
});
|
|
|
|
await expect(reclaimDefinitelyStaleFileLock(lockPath)).resolves.toBe("retained");
|
|
await expect(fs.readFile(lockPath, "utf8")).resolves.toContain(`"pid":${process.pid}`);
|
|
});
|
|
});
|