mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: implement Phase 7a File Lock Manager for concurrent task write coordination
Advisory file-level locking so concurrent tasks coordinate write access safely. - FileLockManager class with acquire/release/releaseAll operations - Per-file locks with automatic expiration (configurable timeout, default 2min) - Re-entrant lock support (same task can re-acquire its own lock) - Lock conflict detection with detailed conflict info - Event system for lock lifecycle (acquired/released/expired) - Reverse index for efficient per-task lock lookup - Path normalization for consistent lock keys - 39 comprehensive unit tests covering all operations Part of Phase 7 (Controlled Write Parallelism) for Issue #12330
This commit is contained in:
parent
35bed0c1b6
commit
9ab2c19d4b
3 changed files with 745 additions and 0 deletions
348
src/services/file-lock/FileLockManager.ts
Normal file
348
src/services/file-lock/FileLockManager.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
import path from "path"
|
||||
|
||||
/**
|
||||
* Information about a file lock held by a task.
|
||||
*/
|
||||
export interface FileLockInfo {
|
||||
/** Absolute normalized path of the locked file */
|
||||
filePath: string
|
||||
/** ID of the task holding the lock */
|
||||
taskId: string
|
||||
/** Timestamp (ms) when the lock was acquired */
|
||||
acquiredAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned when a lock acquisition attempt fails.
|
||||
*/
|
||||
export interface LockConflict {
|
||||
/** The file that is already locked */
|
||||
filePath: string
|
||||
/** The task that currently holds the lock */
|
||||
holdingTaskId: string
|
||||
/** How long (ms) the lock has been held */
|
||||
heldForMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the FileLockManager.
|
||||
*/
|
||||
export type FileLockEvent =
|
||||
| { type: "lock-acquired"; filePath: string; taskId: string }
|
||||
| { type: "lock-released"; filePath: string; taskId: string }
|
||||
| { type: "lock-expired"; filePath: string; taskId: string }
|
||||
| { type: "all-locks-released"; taskId: string; count: number }
|
||||
|
||||
export type FileLockEventListener = (event: FileLockEvent) => void
|
||||
|
||||
export interface FileLockManagerOptions {
|
||||
/**
|
||||
* Maximum duration (ms) a lock can be held before it is forcibly released.
|
||||
* Default: 120_000 (2 minutes).
|
||||
*/
|
||||
lockTimeoutMs?: number
|
||||
}
|
||||
|
||||
const DEFAULT_LOCK_TIMEOUT_MS = 120_000
|
||||
|
||||
/**
|
||||
* Advisory file-level lock manager for coordinating writes across concurrent tasks.
|
||||
*
|
||||
* Locks are "advisory" -- they do not use OS-level file locks. Instead, the
|
||||
* tool execution layer checks the lock manager before allowing write operations.
|
||||
* This keeps the system portable and testable.
|
||||
*
|
||||
* All file paths are normalized to absolute paths using `path.resolve` before
|
||||
* being used as map keys, ensuring consistent lookup regardless of how the
|
||||
* path is specified (relative, absolute, trailing slashes, etc.).
|
||||
*/
|
||||
export class FileLockManager {
|
||||
/**
|
||||
* Map from normalized absolute file path to lock info.
|
||||
*/
|
||||
private locks = new Map<string, FileLockInfo>()
|
||||
|
||||
/**
|
||||
* Reverse index: taskId -> set of normalized file paths locked by that task.
|
||||
*/
|
||||
private taskLocks = new Map<string, Set<string>>()
|
||||
|
||||
/**
|
||||
* Event listeners.
|
||||
*/
|
||||
private listeners: FileLockEventListener[] = []
|
||||
|
||||
/**
|
||||
* Maximum lock hold duration in milliseconds.
|
||||
*/
|
||||
private readonly lockTimeoutMs: number
|
||||
|
||||
constructor(options?: FileLockManagerOptions) {
|
||||
this.lockTimeoutMs = options?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to acquire a write lock on a file for a specific task.
|
||||
*
|
||||
* If the file is already locked by the same task, refreshes the timestamp
|
||||
* and returns true (re-entrant). If locked by a different task, checks
|
||||
* for expiration first -- if the existing lock has expired it is forcibly
|
||||
* released before granting the new lock.
|
||||
*
|
||||
* @returns `true` if the lock was acquired, `false` if another task holds it.
|
||||
*/
|
||||
acquireLock(filePath: string, taskId: string): boolean {
|
||||
const normalized = this.normalizePath(filePath)
|
||||
const existing = this.locks.get(normalized)
|
||||
|
||||
if (existing) {
|
||||
// Re-entrant: same task already holds the lock -- refresh timestamp
|
||||
if (existing.taskId === taskId) {
|
||||
existing.acquiredAt = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if the existing lock has expired
|
||||
if (this.isLockExpired(existing)) {
|
||||
this.forceReleaseLock(normalized, existing.taskId)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire the lock
|
||||
const lockInfo: FileLockInfo = {
|
||||
filePath: normalized,
|
||||
taskId,
|
||||
acquiredAt: Date.now(),
|
||||
}
|
||||
|
||||
this.locks.set(normalized, lockInfo)
|
||||
|
||||
let taskSet = this.taskLocks.get(taskId)
|
||||
if (!taskSet) {
|
||||
taskSet = new Set()
|
||||
this.taskLocks.set(taskId, taskSet)
|
||||
}
|
||||
taskSet.add(normalized)
|
||||
|
||||
this.emit({ type: "lock-acquired", filePath: normalized, taskId })
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a lock held by a specific task.
|
||||
* No-op if the task does not hold the lock.
|
||||
*/
|
||||
releaseLock(filePath: string, taskId: string): void {
|
||||
const normalized = this.normalizePath(filePath)
|
||||
const existing = this.locks.get(normalized)
|
||||
|
||||
if (!existing || existing.taskId !== taskId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.locks.delete(normalized)
|
||||
|
||||
const taskSet = this.taskLocks.get(taskId)
|
||||
if (taskSet) {
|
||||
taskSet.delete(normalized)
|
||||
if (taskSet.size === 0) {
|
||||
this.taskLocks.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
this.emit({ type: "lock-released", filePath: normalized, taskId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all locks held by a specific task.
|
||||
* Called when a task completes, is cancelled, or errors out.
|
||||
*/
|
||||
releaseAllLocks(taskId: string): void {
|
||||
const taskSet = this.taskLocks.get(taskId)
|
||||
if (!taskSet || taskSet.size === 0) {
|
||||
this.taskLocks.delete(taskId)
|
||||
return
|
||||
}
|
||||
|
||||
const count = taskSet.size
|
||||
|
||||
for (const normalized of taskSet) {
|
||||
this.locks.delete(normalized)
|
||||
}
|
||||
|
||||
this.taskLocks.delete(taskId)
|
||||
|
||||
this.emit({ type: "all-locks-released", taskId, count })
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which task (if any) holds the lock on a file.
|
||||
* Checks for expiration -- if the lock is expired, it is released and
|
||||
* `undefined` is returned.
|
||||
*
|
||||
* @returns The taskId of the lock holder, or `undefined` if unlocked.
|
||||
*/
|
||||
getLockHolder(filePath: string): string | undefined {
|
||||
const normalized = this.normalizePath(filePath)
|
||||
const existing = this.locks.get(normalized)
|
||||
|
||||
if (!existing) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (this.isLockExpired(existing)) {
|
||||
this.forceReleaseLock(normalized, existing.taskId)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return existing.taskId
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed lock info for a file, or undefined if not locked.
|
||||
* Checks for expiration.
|
||||
*/
|
||||
getLockInfo(filePath: string): FileLockInfo | undefined {
|
||||
const normalized = this.normalizePath(filePath)
|
||||
const existing = this.locks.get(normalized)
|
||||
|
||||
if (!existing) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (this.isLockExpired(existing)) {
|
||||
this.forceReleaseLock(normalized, existing.taskId)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { ...existing }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conflict details when a lock acquisition would fail.
|
||||
* Returns undefined if the file is not locked by another task.
|
||||
*/
|
||||
getLockConflict(filePath: string, taskId: string): LockConflict | undefined {
|
||||
const normalized = this.normalizePath(filePath)
|
||||
const existing = this.locks.get(normalized)
|
||||
|
||||
if (!existing || existing.taskId === taskId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (this.isLockExpired(existing)) {
|
||||
this.forceReleaseLock(normalized, existing.taskId)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: normalized,
|
||||
holdingTaskId: existing.taskId,
|
||||
heldForMs: Date.now() - existing.acquiredAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all files currently locked by a specific task.
|
||||
*/
|
||||
getLockedFiles(taskId: string): string[] {
|
||||
const taskSet = this.taskLocks.get(taskId)
|
||||
if (!taskSet) {
|
||||
return []
|
||||
}
|
||||
return Array.from(taskSet)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all currently held locks. Primarily for debugging/UI display.
|
||||
* Expired locks are cleaned up during this call.
|
||||
*/
|
||||
getAllLocks(): FileLockInfo[] {
|
||||
const result: FileLockInfo[] = []
|
||||
const expired: Array<{ normalized: string; taskId: string }> = []
|
||||
|
||||
for (const [normalized, info] of this.locks) {
|
||||
if (this.isLockExpired(info)) {
|
||||
expired.push({ normalized, taskId: info.taskId })
|
||||
} else {
|
||||
result.push({ ...info })
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired locks
|
||||
for (const { normalized, taskId } of expired) {
|
||||
this.forceReleaseLock(normalized, taskId)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of active locks.
|
||||
*/
|
||||
get lockCount(): number {
|
||||
return this.locks.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event listener.
|
||||
*/
|
||||
onEvent(listener: FileLockEventListener): void {
|
||||
this.listeners.push(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event listener.
|
||||
*/
|
||||
offEvent(listener: FileLockEventListener): void {
|
||||
const idx = this.listeners.indexOf(listener)
|
||||
if (idx !== -1) {
|
||||
this.listeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all locks and listeners. Primarily for testing.
|
||||
*/
|
||||
dispose(): void {
|
||||
this.locks.clear()
|
||||
this.taskLocks.clear()
|
||||
this.listeners = []
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private normalizePath(filePath: string): string {
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
|
||||
private isLockExpired(info: FileLockInfo): boolean {
|
||||
return Date.now() - info.acquiredAt > this.lockTimeoutMs
|
||||
}
|
||||
|
||||
private forceReleaseLock(normalized: string, taskId: string): void {
|
||||
this.locks.delete(normalized)
|
||||
|
||||
const taskSet = this.taskLocks.get(taskId)
|
||||
if (taskSet) {
|
||||
taskSet.delete(normalized)
|
||||
if (taskSet.size === 0) {
|
||||
this.taskLocks.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
this.emit({ type: "lock-expired", filePath: normalized, taskId })
|
||||
}
|
||||
|
||||
private emit(event: FileLockEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch {
|
||||
// Swallow listener errors to avoid breaking lock operations
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
389
src/services/file-lock/__tests__/FileLockManager.spec.ts
Normal file
389
src/services/file-lock/__tests__/FileLockManager.spec.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import path from "path"
|
||||
import { FileLockManager, type FileLockEvent } from "../FileLockManager"
|
||||
|
||||
describe("FileLockManager", () => {
|
||||
let manager: FileLockManager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new FileLockManager()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
manager.dispose()
|
||||
})
|
||||
|
||||
describe("acquireLock", () => {
|
||||
it("should acquire a lock on an unlocked file", () => {
|
||||
const result = manager.acquireLock("/project/foo.ts", "task-1")
|
||||
expect(result).toBe(true)
|
||||
expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1")
|
||||
})
|
||||
|
||||
it("should allow the same task to re-acquire a lock (re-entrant)", () => {
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
const result = manager.acquireLock("/project/foo.ts", "task-1")
|
||||
expect(result).toBe(true)
|
||||
expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1")
|
||||
})
|
||||
|
||||
it("should deny a lock when another task holds it", () => {
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
const result = manager.acquireLock("/project/foo.ts", "task-2")
|
||||
expect(result).toBe(false)
|
||||
expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1")
|
||||
})
|
||||
|
||||
it("should normalize paths so equivalent paths resolve to the same lock", () => {
|
||||
manager.acquireLock("/project/src/../foo.ts", "task-1")
|
||||
const result = manager.acquireLock("/project/foo.ts", "task-2")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should allow locking multiple different files by the same task", () => {
|
||||
expect(manager.acquireLock("/project/a.ts", "task-1")).toBe(true)
|
||||
expect(manager.acquireLock("/project/b.ts", "task-1")).toBe(true)
|
||||
expect(manager.getLockedFiles("task-1")).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should allow different tasks to lock different files", () => {
|
||||
expect(manager.acquireLock("/project/a.ts", "task-1")).toBe(true)
|
||||
expect(manager.acquireLock("/project/b.ts", "task-2")).toBe(true)
|
||||
expect(manager.getLockHolder("/project/a.ts")).toBe("task-1")
|
||||
expect(manager.getLockHolder("/project/b.ts")).toBe("task-2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("releaseLock", () => {
|
||||
it("should release a lock held by the specified task", () => {
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
manager.releaseLock("/project/foo.ts", "task-1")
|
||||
expect(manager.getLockHolder("/project/foo.ts")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should be a no-op when the task does not hold the lock", () => {
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
manager.releaseLock("/project/foo.ts", "task-2")
|
||||
expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1")
|
||||
})
|
||||
|
||||
it("should be a no-op when the file is not locked", () => {
|
||||
// Should not throw
|
||||
manager.releaseLock("/project/foo.ts", "task-1")
|
||||
})
|
||||
|
||||
it("should allow another task to acquire the lock after release", () => {
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
manager.releaseLock("/project/foo.ts", "task-1")
|
||||
expect(manager.acquireLock("/project/foo.ts", "task-2")).toBe(true)
|
||||
})
|
||||
|
||||
it("should clean up taskLocks when the last lock for a task is released", () => {
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
manager.releaseLock("/project/foo.ts", "task-1")
|
||||
expect(manager.getLockedFiles("task-1")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("releaseAllLocks", () => {
|
||||
it("should release all locks for a task", () => {
|
||||
manager.acquireLock("/project/a.ts", "task-1")
|
||||
manager.acquireLock("/project/b.ts", "task-1")
|
||||
manager.acquireLock("/project/c.ts", "task-1")
|
||||
manager.releaseAllLocks("task-1")
|
||||
|
||||
expect(manager.getLockedFiles("task-1")).toHaveLength(0)
|
||||
expect(manager.getLockHolder("/project/a.ts")).toBeUndefined()
|
||||
expect(manager.getLockHolder("/project/b.ts")).toBeUndefined()
|
||||
expect(manager.getLockHolder("/project/c.ts")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should not affect locks held by other tasks", () => {
|
||||
manager.acquireLock("/project/a.ts", "task-1")
|
||||
manager.acquireLock("/project/b.ts", "task-2")
|
||||
manager.releaseAllLocks("task-1")
|
||||
|
||||
expect(manager.getLockHolder("/project/b.ts")).toBe("task-2")
|
||||
})
|
||||
|
||||
it("should be a no-op when the task has no locks", () => {
|
||||
// Should not throw
|
||||
manager.releaseAllLocks("task-nonexistent")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLockHolder", () => {
|
||||
it("should return undefined for unlocked files", () => {
|
||||
expect(manager.getLockHolder("/project/foo.ts")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return the task ID for locked files", () => {
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLockInfo", () => {
|
||||
it("should return undefined for unlocked files", () => {
|
||||
expect(manager.getLockInfo("/project/foo.ts")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return a copy of the lock info", () => {
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
const info = manager.getLockInfo("/project/foo.ts")
|
||||
expect(info).toBeDefined()
|
||||
expect(info!.taskId).toBe("task-1")
|
||||
expect(info!.filePath).toBe(path.resolve("/project/foo.ts"))
|
||||
expect(info!.acquiredAt).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLockConflict", () => {
|
||||
it("should return undefined when file is not locked", () => {
|
||||
expect(manager.getLockConflict("/project/foo.ts", "task-1")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return undefined when the requesting task holds the lock", () => {
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
expect(manager.getLockConflict("/project/foo.ts", "task-1")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return conflict details when another task holds the lock", () => {
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
const conflict = manager.getLockConflict("/project/foo.ts", "task-2")
|
||||
expect(conflict).toBeDefined()
|
||||
expect(conflict!.holdingTaskId).toBe("task-1")
|
||||
expect(conflict!.filePath).toBe(path.resolve("/project/foo.ts"))
|
||||
expect(conflict!.heldForMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLockedFiles", () => {
|
||||
it("should return empty array for a task with no locks", () => {
|
||||
expect(manager.getLockedFiles("task-1")).toEqual([])
|
||||
})
|
||||
|
||||
it("should return all files locked by a task", () => {
|
||||
manager.acquireLock("/project/a.ts", "task-1")
|
||||
manager.acquireLock("/project/b.ts", "task-1")
|
||||
const files = manager.getLockedFiles("task-1")
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files).toContain(path.resolve("/project/a.ts"))
|
||||
expect(files).toContain(path.resolve("/project/b.ts"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllLocks", () => {
|
||||
it("should return empty array when no locks exist", () => {
|
||||
expect(manager.getAllLocks()).toEqual([])
|
||||
})
|
||||
|
||||
it("should return all active locks", () => {
|
||||
manager.acquireLock("/project/a.ts", "task-1")
|
||||
manager.acquireLock("/project/b.ts", "task-2")
|
||||
const allLocks = manager.getAllLocks()
|
||||
expect(allLocks).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("lockCount", () => {
|
||||
it("should return 0 initially", () => {
|
||||
expect(manager.lockCount).toBe(0)
|
||||
})
|
||||
|
||||
it("should track the number of active locks", () => {
|
||||
manager.acquireLock("/project/a.ts", "task-1")
|
||||
expect(manager.lockCount).toBe(1)
|
||||
manager.acquireLock("/project/b.ts", "task-1")
|
||||
expect(manager.lockCount).toBe(2)
|
||||
manager.releaseLock("/project/a.ts", "task-1")
|
||||
expect(manager.lockCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("lock expiration", () => {
|
||||
it("should auto-expire locks after timeout and allow reacquisition", () => {
|
||||
const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 })
|
||||
|
||||
shortTimeoutManager.acquireLock("/project/foo.ts", "task-1")
|
||||
expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBe("task-1")
|
||||
|
||||
// Simulate time passing by directly manipulating the lock's acquiredAt
|
||||
const locks = (shortTimeoutManager as any).locks as Map<string, any>
|
||||
const normalized = path.resolve("/project/foo.ts")
|
||||
const lockInfo = locks.get(normalized)
|
||||
lockInfo.acquiredAt = Date.now() - 100 // 100ms ago, > 50ms timeout
|
||||
|
||||
// The lock should now be considered expired
|
||||
expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBeUndefined()
|
||||
expect(shortTimeoutManager.acquireLock("/project/foo.ts", "task-2")).toBe(true)
|
||||
|
||||
shortTimeoutManager.dispose()
|
||||
})
|
||||
|
||||
it("should auto-expire locks during acquireLock by another task", () => {
|
||||
const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 })
|
||||
|
||||
shortTimeoutManager.acquireLock("/project/foo.ts", "task-1")
|
||||
|
||||
// Simulate expiry
|
||||
const locks = (shortTimeoutManager as any).locks as Map<string, any>
|
||||
const normalized = path.resolve("/project/foo.ts")
|
||||
locks.get(normalized).acquiredAt = Date.now() - 100
|
||||
|
||||
// Another task should be able to acquire the expired lock
|
||||
expect(shortTimeoutManager.acquireLock("/project/foo.ts", "task-2")).toBe(true)
|
||||
expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBe("task-2")
|
||||
|
||||
shortTimeoutManager.dispose()
|
||||
})
|
||||
|
||||
it("should clean up expired locks during getAllLocks", () => {
|
||||
const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 })
|
||||
|
||||
shortTimeoutManager.acquireLock("/project/a.ts", "task-1")
|
||||
shortTimeoutManager.acquireLock("/project/b.ts", "task-2")
|
||||
|
||||
// Expire only task-1's lock
|
||||
const locks = (shortTimeoutManager as any).locks as Map<string, any>
|
||||
locks.get(path.resolve("/project/a.ts")).acquiredAt = Date.now() - 100
|
||||
|
||||
const allLocks = shortTimeoutManager.getAllLocks()
|
||||
expect(allLocks).toHaveLength(1)
|
||||
expect(allLocks[0].taskId).toBe("task-2")
|
||||
|
||||
shortTimeoutManager.dispose()
|
||||
})
|
||||
|
||||
it("should return undefined from getLockConflict for expired locks", () => {
|
||||
const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 })
|
||||
|
||||
shortTimeoutManager.acquireLock("/project/foo.ts", "task-1")
|
||||
|
||||
// Expire the lock
|
||||
const locks = (shortTimeoutManager as any).locks as Map<string, any>
|
||||
locks.get(path.resolve("/project/foo.ts")).acquiredAt = Date.now() - 100
|
||||
|
||||
expect(shortTimeoutManager.getLockConflict("/project/foo.ts", "task-2")).toBeUndefined()
|
||||
|
||||
shortTimeoutManager.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe("events", () => {
|
||||
it("should emit lock-acquired events", () => {
|
||||
const events: FileLockEvent[] = []
|
||||
manager.onEvent((e) => events.push(e))
|
||||
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].type).toBe("lock-acquired")
|
||||
expect(events[0].taskId).toBe("task-1")
|
||||
})
|
||||
|
||||
it("should emit lock-released events", () => {
|
||||
const events: FileLockEvent[] = []
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
|
||||
manager.onEvent((e) => events.push(e))
|
||||
manager.releaseLock("/project/foo.ts", "task-1")
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].type).toBe("lock-released")
|
||||
})
|
||||
|
||||
it("should emit all-locks-released events", () => {
|
||||
const events: FileLockEvent[] = []
|
||||
manager.acquireLock("/project/a.ts", "task-1")
|
||||
manager.acquireLock("/project/b.ts", "task-1")
|
||||
|
||||
manager.onEvent((e) => events.push(e))
|
||||
manager.releaseAllLocks("task-1")
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].type).toBe("all-locks-released")
|
||||
if (events[0].type === "all-locks-released") {
|
||||
expect(events[0].count).toBe(2)
|
||||
}
|
||||
})
|
||||
|
||||
it("should emit lock-expired events when a lock times out", () => {
|
||||
const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 })
|
||||
const events: FileLockEvent[] = []
|
||||
shortTimeoutManager.onEvent((e) => events.push(e))
|
||||
|
||||
shortTimeoutManager.acquireLock("/project/foo.ts", "task-1")
|
||||
|
||||
// Expire the lock
|
||||
const locks = (shortTimeoutManager as any).locks as Map<string, any>
|
||||
locks.get(path.resolve("/project/foo.ts")).acquiredAt = Date.now() - 100
|
||||
|
||||
// Trigger expiration check via getLockHolder
|
||||
shortTimeoutManager.getLockHolder("/project/foo.ts")
|
||||
|
||||
const expiredEvents = events.filter((e) => e.type === "lock-expired")
|
||||
expect(expiredEvents).toHaveLength(1)
|
||||
|
||||
shortTimeoutManager.dispose()
|
||||
})
|
||||
|
||||
it("should allow removing event listeners", () => {
|
||||
const events: FileLockEvent[] = []
|
||||
const listener = (e: FileLockEvent) => events.push(e)
|
||||
|
||||
manager.onEvent(listener)
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
expect(events).toHaveLength(1)
|
||||
|
||||
manager.offEvent(listener)
|
||||
manager.acquireLock("/project/bar.ts", "task-1")
|
||||
expect(events).toHaveLength(1) // No new events
|
||||
})
|
||||
|
||||
it("should not throw if a listener throws", () => {
|
||||
manager.onEvent(() => {
|
||||
throw new Error("listener error")
|
||||
})
|
||||
|
||||
// Should not throw
|
||||
expect(() => manager.acquireLock("/project/foo.ts", "task-1")).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose", () => {
|
||||
it("should clear all locks and listeners", () => {
|
||||
const events: FileLockEvent[] = []
|
||||
manager.onEvent((e) => events.push(e))
|
||||
|
||||
manager.acquireLock("/project/a.ts", "task-1")
|
||||
manager.acquireLock("/project/b.ts", "task-2")
|
||||
manager.dispose()
|
||||
|
||||
expect(manager.lockCount).toBe(0)
|
||||
expect(manager.getAllLocks()).toEqual([])
|
||||
|
||||
// Listener should have been removed
|
||||
manager.acquireLock("/project/c.ts", "task-3")
|
||||
expect(events).toHaveLength(2) // Only the pre-dispose events
|
||||
})
|
||||
})
|
||||
|
||||
describe("re-entrant lock refresh", () => {
|
||||
it("should refresh the acquiredAt timestamp on re-entrant lock", () => {
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
const info1 = manager.getLockInfo("/project/foo.ts")
|
||||
|
||||
// Small delay to ensure timestamp differs
|
||||
const originalTime = info1!.acquiredAt
|
||||
|
||||
// Manipulate time to verify refresh
|
||||
const locks = (manager as any).locks as Map<string, any>
|
||||
const normalized = path.resolve("/project/foo.ts")
|
||||
locks.get(normalized).acquiredAt = originalTime - 1000
|
||||
|
||||
manager.acquireLock("/project/foo.ts", "task-1")
|
||||
const info2 = manager.getLockInfo("/project/foo.ts")
|
||||
expect(info2!.acquiredAt).toBeGreaterThan(originalTime - 1000)
|
||||
})
|
||||
})
|
||||
})
|
||||
8
src/services/file-lock/index.ts
Normal file
8
src/services/file-lock/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
FileLockManager,
|
||||
type FileLockManagerOptions,
|
||||
type FileLockInfo,
|
||||
type LockConflict,
|
||||
type FileLockEvent,
|
||||
type FileLockEventListener,
|
||||
} from "./FileLockManager"
|
||||
Loading…
Add table
Reference in a new issue