Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 0d89703

Browse files
committed
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
1 parent 28acb6a commit 0d89703

3 files changed

Lines changed: 745 additions & 0 deletions

File tree

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
import path from "path"
2+
3+
/**
4+
* Information about a file lock held by a task.
5+
*/
6+
export interface FileLockInfo {
7+
/** Absolute normalized path of the locked file */
8+
filePath: string
9+
/** ID of the task holding the lock */
10+
taskId: string
11+
/** Timestamp (ms) when the lock was acquired */
12+
acquiredAt: number
13+
}
14+
15+
/**
16+
* Result returned when a lock acquisition attempt fails.
17+
*/
18+
export interface LockConflict {
19+
/** The file that is already locked */
20+
filePath: string
21+
/** The task that currently holds the lock */
22+
holdingTaskId: string
23+
/** How long (ms) the lock has been held */
24+
heldForMs: number
25+
}
26+
27+
/**
28+
* Events emitted by the FileLockManager.
29+
*/
30+
export type FileLockEvent =
31+
| { type: "lock-acquired"; filePath: string; taskId: string }
32+
| { type: "lock-released"; filePath: string; taskId: string }
33+
| { type: "lock-expired"; filePath: string; taskId: string }
34+
| { type: "all-locks-released"; taskId: string; count: number }
35+
36+
export type FileLockEventListener = (event: FileLockEvent) => void
37+
38+
export interface FileLockManagerOptions {
39+
/**
40+
* Maximum duration (ms) a lock can be held before it is forcibly released.
41+
* Default: 120_000 (2 minutes).
42+
*/
43+
lockTimeoutMs?: number
44+
}
45+
46+
const DEFAULT_LOCK_TIMEOUT_MS = 120_000
47+
48+
/**
49+
* Advisory file-level lock manager for coordinating writes across concurrent tasks.
50+
*
51+
* Locks are "advisory" -- they do not use OS-level file locks. Instead, the
52+
* tool execution layer checks the lock manager before allowing write operations.
53+
* This keeps the system portable and testable.
54+
*
55+
* All file paths are normalized to absolute paths using `path.resolve` before
56+
* being used as map keys, ensuring consistent lookup regardless of how the
57+
* path is specified (relative, absolute, trailing slashes, etc.).
58+
*/
59+
export class FileLockManager {
60+
/**
61+
* Map from normalized absolute file path to lock info.
62+
*/
63+
private locks = new Map<string, FileLockInfo>()
64+
65+
/**
66+
* Reverse index: taskId -> set of normalized file paths locked by that task.
67+
*/
68+
private taskLocks = new Map<string, Set<string>>()
69+
70+
/**
71+
* Event listeners.
72+
*/
73+
private listeners: FileLockEventListener[] = []
74+
75+
/**
76+
* Maximum lock hold duration in milliseconds.
77+
*/
78+
private readonly lockTimeoutMs: number
79+
80+
constructor(options?: FileLockManagerOptions) {
81+
this.lockTimeoutMs = options?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
82+
}
83+
84+
/**
85+
* Attempt to acquire a write lock on a file for a specific task.
86+
*
87+
* If the file is already locked by the same task, refreshes the timestamp
88+
* and returns true (re-entrant). If locked by a different task, checks
89+
* for expiration first -- if the existing lock has expired it is forcibly
90+
* released before granting the new lock.
91+
*
92+
* @returns `true` if the lock was acquired, `false` if another task holds it.
93+
*/
94+
acquireLock(filePath: string, taskId: string): boolean {
95+
const normalized = this.normalizePath(filePath)
96+
const existing = this.locks.get(normalized)
97+
98+
if (existing) {
99+
// Re-entrant: same task already holds the lock -- refresh timestamp
100+
if (existing.taskId === taskId) {
101+
existing.acquiredAt = Date.now()
102+
return true
103+
}
104+
105+
// Check if the existing lock has expired
106+
if (this.isLockExpired(existing)) {
107+
this.forceReleaseLock(normalized, existing.taskId)
108+
} else {
109+
return false
110+
}
111+
}
112+
113+
// Acquire the lock
114+
const lockInfo: FileLockInfo = {
115+
filePath: normalized,
116+
taskId,
117+
acquiredAt: Date.now(),
118+
}
119+
120+
this.locks.set(normalized, lockInfo)
121+
122+
let taskSet = this.taskLocks.get(taskId)
123+
if (!taskSet) {
124+
taskSet = new Set()
125+
this.taskLocks.set(taskId, taskSet)
126+
}
127+
taskSet.add(normalized)
128+
129+
this.emit({ type: "lock-acquired", filePath: normalized, taskId })
130+
return true
131+
}
132+
133+
/**
134+
* Release a lock held by a specific task.
135+
* No-op if the task does not hold the lock.
136+
*/
137+
releaseLock(filePath: string, taskId: string): void {
138+
const normalized = this.normalizePath(filePath)
139+
const existing = this.locks.get(normalized)
140+
141+
if (!existing || existing.taskId !== taskId) {
142+
return
143+
}
144+
145+
this.locks.delete(normalized)
146+
147+
const taskSet = this.taskLocks.get(taskId)
148+
if (taskSet) {
149+
taskSet.delete(normalized)
150+
if (taskSet.size === 0) {
151+
this.taskLocks.delete(taskId)
152+
}
153+
}
154+
155+
this.emit({ type: "lock-released", filePath: normalized, taskId })
156+
}
157+
158+
/**
159+
* Release all locks held by a specific task.
160+
* Called when a task completes, is cancelled, or errors out.
161+
*/
162+
releaseAllLocks(taskId: string): void {
163+
const taskSet = this.taskLocks.get(taskId)
164+
if (!taskSet || taskSet.size === 0) {
165+
this.taskLocks.delete(taskId)
166+
return
167+
}
168+
169+
const count = taskSet.size
170+
171+
for (const normalized of taskSet) {
172+
this.locks.delete(normalized)
173+
}
174+
175+
this.taskLocks.delete(taskId)
176+
177+
this.emit({ type: "all-locks-released", taskId, count })
178+
}
179+
180+
/**
181+
* Check which task (if any) holds the lock on a file.
182+
* Checks for expiration -- if the lock is expired, it is released and
183+
* `undefined` is returned.
184+
*
185+
* @returns The taskId of the lock holder, or `undefined` if unlocked.
186+
*/
187+
getLockHolder(filePath: string): string | undefined {
188+
const normalized = this.normalizePath(filePath)
189+
const existing = this.locks.get(normalized)
190+
191+
if (!existing) {
192+
return undefined
193+
}
194+
195+
if (this.isLockExpired(existing)) {
196+
this.forceReleaseLock(normalized, existing.taskId)
197+
return undefined
198+
}
199+
200+
return existing.taskId
201+
}
202+
203+
/**
204+
* Get detailed lock info for a file, or undefined if not locked.
205+
* Checks for expiration.
206+
*/
207+
getLockInfo(filePath: string): FileLockInfo | undefined {
208+
const normalized = this.normalizePath(filePath)
209+
const existing = this.locks.get(normalized)
210+
211+
if (!existing) {
212+
return undefined
213+
}
214+
215+
if (this.isLockExpired(existing)) {
216+
this.forceReleaseLock(normalized, existing.taskId)
217+
return undefined
218+
}
219+
220+
return { ...existing }
221+
}
222+
223+
/**
224+
* Get the conflict details when a lock acquisition would fail.
225+
* Returns undefined if the file is not locked by another task.
226+
*/
227+
getLockConflict(filePath: string, taskId: string): LockConflict | undefined {
228+
const normalized = this.normalizePath(filePath)
229+
const existing = this.locks.get(normalized)
230+
231+
if (!existing || existing.taskId === taskId) {
232+
return undefined
233+
}
234+
235+
if (this.isLockExpired(existing)) {
236+
this.forceReleaseLock(normalized, existing.taskId)
237+
return undefined
238+
}
239+
240+
return {
241+
filePath: normalized,
242+
holdingTaskId: existing.taskId,
243+
heldForMs: Date.now() - existing.acquiredAt,
244+
}
245+
}
246+
247+
/**
248+
* List all files currently locked by a specific task.
249+
*/
250+
getLockedFiles(taskId: string): string[] {
251+
const taskSet = this.taskLocks.get(taskId)
252+
if (!taskSet) {
253+
return []
254+
}
255+
return Array.from(taskSet)
256+
}
257+
258+
/**
259+
* Get all currently held locks. Primarily for debugging/UI display.
260+
* Expired locks are cleaned up during this call.
261+
*/
262+
getAllLocks(): FileLockInfo[] {
263+
const result: FileLockInfo[] = []
264+
const expired: Array<{ normalized: string; taskId: string }> = []
265+
266+
for (const [normalized, info] of this.locks) {
267+
if (this.isLockExpired(info)) {
268+
expired.push({ normalized, taskId: info.taskId })
269+
} else {
270+
result.push({ ...info })
271+
}
272+
}
273+
274+
// Clean up expired locks
275+
for (const { normalized, taskId } of expired) {
276+
this.forceReleaseLock(normalized, taskId)
277+
}
278+
279+
return result
280+
}
281+
282+
/**
283+
* Get the total number of active locks.
284+
*/
285+
get lockCount(): number {
286+
return this.locks.size
287+
}
288+
289+
/**
290+
* Register an event listener.
291+
*/
292+
onEvent(listener: FileLockEventListener): void {
293+
this.listeners.push(listener)
294+
}
295+
296+
/**
297+
* Remove an event listener.
298+
*/
299+
offEvent(listener: FileLockEventListener): void {
300+
const idx = this.listeners.indexOf(listener)
301+
if (idx !== -1) {
302+
this.listeners.splice(idx, 1)
303+
}
304+
}
305+
306+
/**
307+
* Clear all locks and listeners. Primarily for testing.
308+
*/
309+
dispose(): void {
310+
this.locks.clear()
311+
this.taskLocks.clear()
312+
this.listeners = []
313+
}
314+
315+
// --- Private helpers ---
316+
317+
private normalizePath(filePath: string): string {
318+
return path.resolve(filePath)
319+
}
320+
321+
private isLockExpired(info: FileLockInfo): boolean {
322+
return Date.now() - info.acquiredAt > this.lockTimeoutMs
323+
}
324+
325+
private forceReleaseLock(normalized: string, taskId: string): void {
326+
this.locks.delete(normalized)
327+
328+
const taskSet = this.taskLocks.get(taskId)
329+
if (taskSet) {
330+
taskSet.delete(normalized)
331+
if (taskSet.size === 0) {
332+
this.taskLocks.delete(taskId)
333+
}
334+
}
335+
336+
this.emit({ type: "lock-expired", filePath: normalized, taskId })
337+
}
338+
339+
private emit(event: FileLockEvent): void {
340+
for (const listener of this.listeners) {
341+
try {
342+
listener(event)
343+
} catch {
344+
// Swallow listener errors to avoid breaking lock operations
345+
}
346+
}
347+
}
348+
}

0 commit comments

Comments
 (0)