mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-01 23:31:33 +00:00
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>
127 lines
3.6 KiB
TypeScript
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();
|
|
}
|
|
}
|