import { LockHandle } from '../utils/types.js';
import { LockTimeoutError } from '../utils/errors.js';
import crypto from 'crypto';
interface Lock {
type: 'read' | 'write';
acquiredAt: number;
id: string;
}
export class FileLockManager {
private locks: Map<string, Lock[]> = new Map();
private lockTimeout: number = 30000; // 30 seconds max lock time
private cleanupInterval: NodeJS.Timeout | null = null;
constructor(cleanupIntervalMs: number = 60000) {
this.startCleanup(cleanupIntervalMs);
}
private startCleanup(intervalMs: number): void {
this.cleanupInterval = setInterval(() => {
this.cleanupExpiredLocks();
}, intervalMs);
}
async acquireWriteLock(filepath: string, timeout: number = 5000): Promise<LockHandle> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const existingLocks = this.locks.get(filepath) || [];
// Cannot write if any locks exist (read or write)
if (existingLocks.length === 0) {
return this.createLock(filepath, 'write');
}
await this.sleep(50);
}
throw new LockTimeoutError(`Could not acquire write lock for ${filepath}`);
}
async acquireReadLock(filepath: string, timeout: number = 5000): Promise<LockHandle> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const existingLocks = this.locks.get(filepath) || [];
// Can read if no write locks (multiple read locks OK)
const hasWriteLock = existingLocks.some((lock) => lock.type === 'write');
if (!hasWriteLock) {
return this.createLock(filepath, 'read');
}
await this.sleep(50);
}
throw new LockTimeoutError(`Could not acquire read lock for ${filepath}`);
}
private createLock(filepath: string, type: 'read' | 'write'): LockHandle {
const lock: Lock = {
type,
acquiredAt: Date.now(),
id: crypto.randomUUID(),
};
const existingLocks = this.locks.get(filepath) || [];
this.locks.set(filepath, [...existingLocks, lock]);
return {
filepath,
type,
acquiredAt: lock.acquiredAt,
release: () => this.releaseLock(filepath, lock.id),
};
}
private releaseLock(filepath: string, lockId: string): void {
const existingLocks = this.locks.get(filepath) || [];
const filtered = existingLocks.filter((lock) => lock.id !== lockId);
if (filtered.length === 0) {
this.locks.delete(filepath);
} else {
this.locks.set(filepath, filtered);
}
}
isLocked(filepath: string): boolean {
const locks = this.locks.get(filepath);
return locks !== undefined && locks.length > 0;
}
cleanupExpiredLocks(): void {
const now = Date.now();
for (const [filepath, locks] of this.locks.entries()) {
const validLocks = locks.filter((lock) => now - lock.acquiredAt < this.lockTimeout);
if (validLocks.length === 0) {
this.locks.delete(filepath);
} else if (validLocks.length < locks.length) {
this.locks.set(filepath, validLocks);
console.warn(
`Cleaned up ${locks.length - validLocks.length} expired locks for ${filepath}`
);
}
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.locks.clear();
}
}