singularity-forge/packages/pi-coding-agent/src/core/lock-utils.ts
frizynn 23d0ea656d refactor: extract shared file lock utilities
Extract the duplicated file lock mechanism from auth-storage.ts and
session-manager.ts into a shared lock-utils.ts module.

- acquireLockSyncWithRetry(): throwing variant (used by auth-storage)
- tryAcquireLockSync(): non-throwing variant (used by session-manager)
- acquireLockAsync(): async lock with retries and staleness detection

Removes ~55 lines of duplicated retry-loop logic. The shared module
also provides a foundation for deduplicating identical patterns in
settings-manager.ts and models-json-writer.ts.
2026-03-19 15:16:56 -03:00

113 lines
3.2 KiB
TypeScript

/**
* Shared file-locking utilities built on `proper-lockfile`.
*
* Centralises the synchronous retry-loop and async lock/release patterns
* that were previously duplicated across auth-storage, session-manager,
* settings-manager, and models-json-writer.
*/
import lockfile from "proper-lockfile";
const DEFAULT_MAX_ATTEMPTS = 10;
const DEFAULT_DELAY_MS = 20;
/**
* Acquire a synchronous file lock with retry.
*
* Retries up to `maxAttempts` times when the lock is held by another process
* (ELOCKED), using a busy-wait between attempts.
*
* @returns A release function to unlock.
* @throws On non-ELOCKED errors or when all attempts are exhausted.
*/
export function acquireLockSyncWithRetry(
lockPath: string,
maxAttempts: number = DEFAULT_MAX_ATTEMPTS,
delayMs: number = DEFAULT_DELAY_MS,
): () => void {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return lockfile.lockSync(lockPath, { realpath: false });
} catch (error) {
const code =
typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
if (code !== "ELOCKED" || attempt === maxAttempts) {
throw error;
}
lastError = error;
const start = Date.now();
while (Date.now() - start < delayMs) {
// Busy-wait to avoid changing callers to async.
}
}
}
throw (lastError as Error) ?? new Error("Failed to acquire file lock");
}
/**
* Non-throwing variant of {@link acquireLockSyncWithRetry}.
*
* Returns `undefined` instead of throwing when the lock cannot be acquired,
* allowing callers to proceed without the lock rather than losing data.
*/
export function tryAcquireLockSync(
lockPath: string,
maxAttempts: number = DEFAULT_MAX_ATTEMPTS,
delayMs: number = DEFAULT_DELAY_MS,
): (() => void) | undefined {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return lockfile.lockSync(lockPath, { realpath: false });
} catch (error) {
const code =
typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
if (code !== "ELOCKED" || attempt === maxAttempts) {
// Non-fatal: proceed without lock rather than losing data
return undefined;
}
const start = Date.now();
while (Date.now() - start < delayMs) {
// Busy-wait to avoid changing callers to async.
}
}
}
return undefined;
}
export interface AsyncLockOptions {
/** Maximum staleness in ms before the lock is considered stale. */
staleMs?: number;
/** Called if the lock is compromised while held. */
onCompromised?: (err: Error) => void;
}
/**
* Acquire an async file lock with retries and optional staleness detection.
*
* Uses `proper-lockfile`'s async API with exponential-backoff retries.
*
* @returns A release function (async) to unlock.
*/
export async function acquireLockAsync(
lockPath: string,
options?: AsyncLockOptions,
): Promise<() => Promise<void>> {
return lockfile.lock(lockPath, {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 10000,
randomize: true,
},
stale: options?.staleMs,
onCompromised: options?.onCompromised,
});
}