/** * 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>); 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}`); }); });