agent-ecosystem/src/main/services/team/fileLock.ts
2026-05-30 17:43:08 +03:00

200 lines
5.4 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
const STALE_TIMEOUT_MS = 30_000;
const ACQUIRE_TIMEOUT_MS = 5_000;
const RETRY_INTERVAL_MS = 20;
export interface FileLockOptions {
acquireTimeoutMs?: number;
staleTimeoutMs?: number;
retryIntervalMs?: number;
}
interface LockInfo {
pid: number | null;
ageMs: number | null;
}
function readLockInfo(lockPath: string): LockInfo {
let pid: number | null = null;
let ageMs: number | null = null;
try {
const content = fs.readFileSync(lockPath, 'utf8');
const lines = content.split('\n');
const parsedPid = parseInt(lines[0] ?? '', 10);
if (Number.isFinite(parsedPid) && parsedPid > 0) {
pid = parsedPid;
}
const ts = parseInt(lines[1] ?? '', 10);
if (Number.isFinite(ts)) {
ageMs = Date.now() - ts;
}
} catch {
/* lock may have been released concurrently */
}
if (ageMs === null) {
try {
ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
} catch {
/* lock may have been released concurrently */
}
}
return { pid, ageMs };
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return code !== 'ESRCH';
}
}
function shouldBreakExistingLock(lockPath: string, staleTimeoutMs: number): boolean {
const info = readLockInfo(lockPath);
if (info.pid !== null && !isProcessAlive(info.pid)) {
return true;
}
return info.ageMs !== null && info.ageMs > staleTimeoutMs;
}
function removeLockPath(lockPath: string): void {
try {
fs.rmSync(lockPath, { recursive: true, force: true });
} catch {
/* another process may have cleaned it */
}
}
function writeLockFile(lockPath: string): void {
const fd = fs.openSync(lockPath, 'wx');
let closeError: unknown = null;
try {
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
} finally {
try {
fs.closeSync(fd);
} catch (err) {
closeError = err;
}
}
if (closeError) {
throw closeError instanceof Error ? closeError : new Error('Failed to close file lock');
}
}
function isExistingLockError(code: string | undefined): boolean {
return code === 'EEXIST' || code === 'EISDIR';
}
function tryAcquire(lockPath: string, options: Required<FileLockOptions>): boolean {
try {
// Fast path: assume the lock directory already exists (the common case once a
// team dir is created). This drops an existsSync(dir) stat from EVERY acquire,
// which adds up across the many lock cycles during a team launch.
writeLockFile(lockPath);
return true;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
// Lock directory missing - create it lazily and acquire in the same call, so
// first-acquire latency in a fresh dir is unchanged.
try {
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
writeLockFile(lockPath);
return true;
} catch (retryError) {
const retryCode = (retryError as NodeJS.ErrnoException).code;
if (retryCode === 'ENOENT') {
return false;
}
if (isExistingLockError(retryCode)) {
if (shouldBreakExistingLock(lockPath, options.staleTimeoutMs)) {
removeLockPath(lockPath);
}
return false;
}
throw retryError;
}
}
if (isExistingLockError(code)) {
if (shouldBreakExistingLock(lockPath, options.staleTimeoutMs)) {
removeLockPath(lockPath);
}
return false;
}
throw err;
}
}
function releaseLock(lockPath: string): void {
try {
fs.unlinkSync(lockPath);
} catch {
/* already released or cleaned up */
}
}
function sleepSync(ms: number): void {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
// Synchronous callers need the same cross-process lock as controller writes.
}
}
function resolveLockOptions(options: FileLockOptions): Required<FileLockOptions> {
return {
acquireTimeoutMs: options.acquireTimeoutMs ?? ACQUIRE_TIMEOUT_MS,
staleTimeoutMs: options.staleTimeoutMs ?? STALE_TIMEOUT_MS,
retryIntervalMs: options.retryIntervalMs ?? RETRY_INTERVAL_MS,
};
}
export function withFileLockSync<T>(
filePath: string,
fn: () => T,
options: FileLockOptions = {}
): T {
const resolvedOptions = resolveLockOptions(options);
const lockPath = `${filePath}.lock`;
const deadline = Date.now() + resolvedOptions.acquireTimeoutMs;
while (!tryAcquire(lockPath, resolvedOptions)) {
if (Date.now() >= deadline) {
throw new Error(`File lock timeout: ${filePath}`);
}
sleepSync(Math.min(resolvedOptions.retryIntervalMs, Math.max(0, deadline - Date.now())));
}
try {
return fn();
} finally {
releaseLock(lockPath);
}
}
export async function withFileLock<T>(
filePath: string,
fn: () => Promise<T>,
options: FileLockOptions = {}
): Promise<T> {
const resolvedOptions = resolveLockOptions(options);
const lockPath = `${filePath}.lock`;
const deadline = Date.now() + resolvedOptions.acquireTimeoutMs;
while (!tryAcquire(lockPath, resolvedOptions)) {
if (Date.now() >= deadline) {
throw new Error(`File lock timeout: ${filePath}`);
}
await new Promise((resolve) => setTimeout(resolve, resolvedOptions.retryIntervalMs));
}
try {
return await fn();
} finally {
releaseLock(lockPath);
}
}