Files
openclaw/src/plugin-sdk/file-lock.ts
Alix-007 daa7b1d06b fix(lock): require owner identity proof before stale removal
Fixes #86814.

Reclaims stale plugin lock files only when the previous owner is provably gone or the recorded process start time proves PID reuse. Timestamp age alone now stays fail-closed for PID-owned locks, preserving mutual exclusion for long-running writers while still allowing pidless expired locks to expire.

Verification:
- pnpm test src/infra/stale-lock-file.test.ts src/plugin-sdk/file-lock.test.ts
- pnpm tool-display:check
- git diff --check
- autoreview --mode branch --base origin/main

Known CI note: check-guards failed in deps:shrinkwrap:check because npm resolved newer AWS transitive versions than pnpm-lock.yaml contains; no package or lock files are changed in this PR.

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-05-26 21:38:35 +01:00

127 lines
3.6 KiB
TypeScript

import "../infra/fs-safe-defaults.js";
import {
acquireFileLock as acquireFsSafeFileLock,
drainFileLockManagerForTest,
resetFileLockManagerForTest,
} from "@openclaw/fs-safe/file-lock";
import { shouldRemoveDeadOwnerOrExpiredLock } from "../infra/stale-lock-file.js";
import { getProcessStartTime } from "../shared/pid-alive.js";
export type FileLockOptions = {
retries: {
retries: number;
factor: number;
minTimeout: number;
maxTimeout: number;
randomize?: boolean;
};
stale: number;
};
export type FileLockHandle = {
lockPath: string;
release: () => Promise<void>;
};
export const FILE_LOCK_TIMEOUT_ERROR_CODE = "file_lock_timeout";
export const FILE_LOCK_STALE_ERROR_CODE = "file_lock_stale";
export type FileLockTimeoutError = Error & {
code: typeof FILE_LOCK_TIMEOUT_ERROR_CODE;
lockPath: string;
};
export type FileLockStaleError = Error & {
code: typeof FILE_LOCK_STALE_ERROR_CODE;
lockPath: string;
};
const FILE_LOCK_MANAGER_KEY = "openclaw.plugin-sdk.file-lock";
async function shouldReclaimPluginLock(params: {
lockPath: string;
payload: Record<string, unknown> | null;
staleMs: number;
nowMs: number;
}): Promise<boolean> {
return shouldRemoveDeadOwnerOrExpiredLock({
payload: params.payload,
staleMs: params.staleMs,
nowMs: params.nowMs,
});
}
function normalizeLockError(err: unknown): never {
if ((err as { code?: unknown }).code === FILE_LOCK_TIMEOUT_ERROR_CODE) {
throw Object.assign(new Error((err as Error).message), {
code: FILE_LOCK_TIMEOUT_ERROR_CODE,
lockPath: (err as { lockPath?: string }).lockPath ?? "",
}) as FileLockTimeoutError;
}
if ((err as { code?: unknown }).code === FILE_LOCK_STALE_ERROR_CODE) {
throw Object.assign(new Error((err as Error).message), {
code: FILE_LOCK_STALE_ERROR_CODE,
lockPath: (err as { lockPath?: string }).lockPath ?? "",
}) as FileLockStaleError;
}
throw err;
}
export function resetFileLockStateForTest(): void {
resetFileLockManagerForTest(FILE_LOCK_MANAGER_KEY, FILE_LOCK_MANAGER_KEY);
}
export async function drainFileLockStateForTest(): Promise<void> {
await drainFileLockManagerForTest(FILE_LOCK_MANAGER_KEY, FILE_LOCK_MANAGER_KEY);
}
/** Acquire a re-entrant process-local file lock backed by a `.lock` sidecar file. */
export async function acquireFileLock(
filePath: string,
options: FileLockOptions,
): Promise<FileLockHandle> {
try {
const lock = await acquireFsSafeFileLock(filePath, {
managerKey: FILE_LOCK_MANAGER_KEY,
staleMs: options.stale,
retry: options.retries,
staleRecovery: "remove-if-unchanged",
allowReentrant: true,
payload: () => {
const payload: Record<string, unknown> = {
pid: process.pid,
createdAt: new Date().toISOString(),
};
const starttime = getProcessStartTime(process.pid);
if (starttime !== null) {
payload.starttime = starttime;
}
return payload;
},
shouldReclaim: shouldReclaimPluginLock,
shouldRemoveStaleLock: (snapshot) =>
shouldRemoveDeadOwnerOrExpiredLock({
payload: snapshot.payload,
staleMs: options.stale,
}),
});
return { lockPath: lock.lockPath, release: lock.release };
} catch (err) {
return normalizeLockError(err);
}
}
/** Run an async callback while holding a file lock, always releasing the lock afterward. */
export async function withFileLock<T>(
filePath: string,
options: FileLockOptions,
fn: () => Promise<T>,
): Promise<T> {
const lock = await acquireFileLock(filePath, options);
try {
return await fn();
} finally {
await lock.release();
}
}