acquireFileLock() and withFileLock() provide a cross-process file lock with retry and process-exit cleanup. The lock is implemented as a sidecar file (e.g. state.json ↔ state.json.lock) — only one acquirer can create the sidecar with O_CREAT | O_EXCL at a time.
import { acquireFileLock } from "@openclaw/fs-safe/file-lock";
const handle = await acquireFileLock("/var/lib/app/state.json", {
managerKey: "snapshot",
staleMs: 5 * 60_000,
payload: async () => ({ pid: process.pid, host: os.hostname() }),
});
try {
// ...exclusive work on /var/lib/app/state.json...
} finally {
await handle.release();
}The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via shouldReclaim (defaulting to "is the lock older than staleMs?") — whether it should keep waiting or fail.
The library installs a process.on("exit") handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars. Crashed holders leave their sidecar behind; remove those through an application-owned recovery path after you have proved the holder cannot still be writing.
function acquireFileLock<TPayload>(
targetPath: string,
options: FileLockAcquireOptions<TPayload>,
): Promise<FileLockHandle>;
function withFileLock<T, TPayload>(
targetPath: string,
options: FileLockAcquireOptions<TPayload>,
fn: () => Promise<T>,
): Promise<T>;
function createFileLockManager(key: string): FileLockManager;managerKey is an optional identifier used to keep state isolated across multiple lock domains in the same process. Use distinct keys for distinct domains ("snapshot", "compact", "build"). If omitted, fs-safe derives one from the target path.
type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
managerKey?: string; // optional in-process manager namespace
lockPath?: string; // override; defaults to `${targetPath}.lock`
staleMs?: number; // default 30_000
timeoutMs?: number; // overall acquire deadline; default unbounded
retry?: FileLockRetryOptions;
staleRecovery?: "fail-closed" | "remove-if-unchanged"; // default "fail-closed"
allowReentrant?: boolean; // if this process already holds it, increment a count instead of failing
payload: () => TPayload | Promise<TPayload>;
shouldReclaim?: (params: {
lockPath: string;
normalizedTargetPath: string;
payload: Record<string, unknown> | null;
staleMs: number;
nowMs: number;
heldByThisProcess: boolean;
}) => boolean | Promise<boolean>;
shouldRemoveStaleLock?: (snapshot: {
lockPath: string;
normalizedTargetPath: string;
raw: string;
payload: Record<string, unknown> | null;
}) => boolean | Promise<boolean>;
metadata?: Record<string, unknown>; // attached to heldEntries() output for diagnostics
};
type FileLockRetryOptions = {
retries?: number; // number of retry attempts after the first failure
factor?: number; // exponential backoff factor (default 2)
minTimeout?: number; // initial delay (ms)
maxTimeout?: number; // delay cap (ms)
randomize?: boolean; // jitter
};payload is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
type FileLockHandle = {
lockPath: string;
normalizedTargetPath: string;
release: () => Promise<void>;
[Symbol.asyncDispose](): Promise<void>;
};Always release in a finally:
const handle = await acquireFileLock(targetPath, {
staleMs: 60_000,
payload: () => ({ pid: process.pid }),
});
try {
await doExclusiveWork();
} finally {
await handle.release();
}If your process dies before release() runs and skips the exit handler, the sidecar remains. Once staleMs elapses (or your shouldReclaim returns true), acquisition fails closed by default instead of deleting by path.
const result = await withFileLock(
"/var/lib/app/state.json",
{
managerKey: "compact",
staleMs: 30_000,
payload: () => ({ pid: process.pid, what: "compact" }),
},
async () => {
return await runCompaction();
},
);Acquires, runs fn, releases regardless of success/failure. Returns the result of fn.
Most callers should use acquireFileLock() or withFileLock(). Use createFileLockManager(key) only when a long-lived service needs diagnostics or lifecycle control over locks it currently holds:
const locks = createFileLockManager("session-writes");
const handle = await locks.acquire(sessionPath, {
staleMs: 60_000,
payload: () => ({ pid: process.pid }),
});
for (const held of locks.heldEntries()) {
console.log(held.lockPath, held.acquiredAt);
}
await handle.release();
await locks.drain();The default policy treats locks whose createdAt is older than staleMs as stale. Pass a custom callback when you want a richer notion of "is the holder still alive":
import { kill } from "node:process";
const handle = await acquireFileLock(targetPath, {
staleMs: 60_000,
payload: () => ({ pid: process.pid }),
shouldReclaim: ({ payload, nowMs, staleMs }) => {
if (!payload) return true;
const pid = Number(payload.pid);
if (!Number.isFinite(pid)) return true;
try {
kill(pid, 0);
return false; // process still alive — keep waiting
} catch {
return true; // process gone — fail closed for recovery
}
},
});heldByThisProcess is true when this manager already holds the lock (relevant for the reentrant case). A true result marks the observed sidecar as stale; staleRecovery then decides whether acquisition fails closed or tries caller-approved removal.
The default staleRecovery: "fail-closed" never removes third-party sidecars. Use staleRecovery: "remove-if-unchanged" only when your app has a reliable owner-liveness policy and can prove a stale owner cannot still be writing.
const handle = await acquireFileLock(targetPath, {
staleMs: 60_000,
staleRecovery: "remove-if-unchanged",
payload: () => ({ pid: process.pid, createdAt: new Date().toISOString() }),
shouldReclaim: ({ payload }) => {
const pid = Number(payload?.pid);
return Number.isInteger(pid) && pid > 0 && ownerIsDefinitelyDead(pid);
},
shouldRemoveStaleLock: ({ payload }) => {
const pid = Number(payload?.pid);
return Number.isInteger(pid) && pid > 0 && ownerIsDefinitelyDead(pid);
},
});shouldRemoveStaleLock receives the exact lock snapshot that fs-safe inspected. fs-safe re-reads the sidecar and removes it only if the raw content and file identity are unchanged. If the callback is missing, returns false, or the file changed, acquisition fails closed or keeps retrying according to the normal retry policy.
- Two processes writing the same file at once.
acquireserializes the critical section. - Accidentally deleting a fresh lock during stale recovery. Stale third-party locks fail closed by default. Opt-in removal rechecks the observed snapshot before unlinking.
- Race between simultaneous acquire attempts.
O_CREAT | O_EXCLensures one wins.
- Misbehaving holders that ignore the lock. Locks are advisory — only callers that go through
acquireare bound. - Automatic stale lock deletion. If a process crashes, use the payload and your own supervisor/process table to decide when removal is safe, then opt into
remove-if-unchanged. - Multi-host coordination over network filesystems. Behavior depends on the underlying filesystem's
O_EXCLsemantics; treat as best-effort.
await withFileLock(
"/var/lib/app/db.sqlite",
{
staleMs: 30_000,
payload: () => ({ pid: process.pid, what: "compact" }),
},
async () => {
await runCompaction();
},
);try {
await withFileLock(
targetPath,
{ staleMs: 30_000, retry: { retries: 0 }, payload: () => ({ pid: process.pid }) },
async () => await work(),
);
} catch (err) {
console.log("another process is doing this; skipping");
}await withFileLock(
targetPath,
{
staleMs: 60_000,
timeoutMs: 30_000,
retry: { retries: 30, minTimeout: 100, maxTimeout: 5_000, factor: 1.7, randomize: true },
payload: () => ({ pid: process.pid }),
},
async () => await work(),
);- Atomic writes — single-writer atomicity that often replaces the need for a lock entirely.
createAsyncLockfrom@openclaw/fs-safe/advanced— in-process serialization for a single Node process.