Skip to content

Commit 7771918

Browse files
committed
feat(process-lock): add process lock helpers with stale detection
Add ProcessLockManager class providing cross-platform inter-process synchronization using file-system based locks. Features include: - Atomic lock acquisition via mkdir - Stale lock detection and automatic cleanup - Exponential backoff with jitter for retries - Process exit handlers for guaranteed cleanup - Convenient withLock() helper for scoped locking Includes comprehensive test suite with describe.sequential for proper isolation.
1 parent 99023cf commit 7771918

3 files changed

Lines changed: 562 additions & 0 deletions

File tree

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,10 @@
384384
"types": "./plugins/babel-plugin-inline-require-calls.d.ts",
385385
"default": "./plugins/babel-plugin-inline-require-calls.js"
386386
},
387+
"./process-lock": {
388+
"types": "./dist/process-lock.d.ts",
389+
"default": "./dist/process-lock.js"
390+
},
387391
"./promise-queue": {
388392
"types": "./dist/promise-queue.d.ts",
389393
"default": "./dist/promise-queue.js"

src/process-lock.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* @fileoverview Process locking utilities with stale detection and exit cleanup.
3+
* Provides cross-platform inter-process synchronization using file-system based locks.
4+
*/
5+
6+
import { existsSync, mkdirSync, statSync } from 'node:fs'
7+
8+
import { safeDeleteSync } from './fs'
9+
import { logger } from './logger'
10+
import { pRetry } from './promises'
11+
import { onExit } from './signal-exit'
12+
13+
/**
14+
* Lock acquisition options.
15+
*/
16+
export interface ProcessLockOptions {
17+
/**
18+
* Maximum number of retry attempts.
19+
* @default 3
20+
*/
21+
retries?: number | undefined
22+
23+
/**
24+
* Base delay between retries in milliseconds.
25+
* @default 100
26+
*/
27+
baseDelayMs?: number | undefined
28+
29+
/**
30+
* Maximum delay between retries in milliseconds.
31+
* @default 1000
32+
*/
33+
maxDelayMs?: number | undefined
34+
35+
/**
36+
* Stale lock timeout in milliseconds.
37+
* Locks older than this are considered abandoned and can be reclaimed.
38+
* Aligned with npm's npx locking strategy (5-10 seconds).
39+
* @default 10000 (10 seconds)
40+
*/
41+
staleMs?: number | undefined
42+
}
43+
44+
/**
45+
* Process lock manager with stale detection and exit cleanup.
46+
* Provides cross-platform inter-process synchronization using file-system
47+
* based locks.
48+
*/
49+
class ProcessLockManager {
50+
private activeLocks = new Set<string>()
51+
private exitHandlerRegistered = false
52+
53+
/**
54+
* Ensure process exit handler is registered for cleanup.
55+
* Registers a handler that cleans up all active locks when the process exits.
56+
*/
57+
private ensureExitHandler() {
58+
if (this.exitHandlerRegistered) {
59+
return
60+
}
61+
62+
onExit(() => {
63+
for (const lockPath of this.activeLocks) {
64+
try {
65+
if (existsSync(lockPath)) {
66+
safeDeleteSync(lockPath, { recursive: true })
67+
}
68+
} catch {
69+
// Ignore cleanup errors during exit
70+
}
71+
}
72+
})
73+
74+
this.exitHandlerRegistered = true
75+
}
76+
77+
/**
78+
* Check if a lock is stale based on mtime.
79+
* A lock is considered stale if it's older than the specified timeout,
80+
* indicating the holding process likely died abnormally.
81+
*
82+
* @param lockPath - Path to the lock directory
83+
* @param staleMs - Stale timeout in milliseconds
84+
* @returns True if lock exists and is stale
85+
*/
86+
private isStale(lockPath: string, staleMs: number): boolean {
87+
try {
88+
if (!existsSync(lockPath)) {
89+
return false
90+
}
91+
92+
const stats = statSync(lockPath)
93+
const age = Date.now() - stats.mtime.getTime()
94+
return age > staleMs
95+
} catch {
96+
return false
97+
}
98+
}
99+
100+
/**
101+
* Acquire a lock using mkdir for atomic operation.
102+
* Handles stale locks and includes exit cleanup.
103+
*
104+
* This method attempts to create a lock directory atomically. If the lock
105+
* already exists, it checks if it's stale and removes it before retrying.
106+
* Uses exponential backoff with jitter for retry attempts.
107+
*
108+
* @param lockPath - Path to the lock directory
109+
* @param options - Lock acquisition options
110+
* @returns Release function to unlock
111+
* @throws Error if lock cannot be acquired after all retries
112+
*
113+
* @example
114+
* ```typescript
115+
* const release = await processLock.acquire('/tmp/my-lock')
116+
* try {
117+
* // Critical section
118+
* } finally {
119+
* release()
120+
* }
121+
* ```
122+
*/
123+
async acquire(
124+
lockPath: string,
125+
options: ProcessLockOptions = {},
126+
): Promise<() => void> {
127+
const {
128+
baseDelayMs = 100,
129+
maxDelayMs = 1000,
130+
retries = 3,
131+
staleMs = 10000,
132+
} = options
133+
134+
// Ensure exit handler is registered before any lock acquisition
135+
this.ensureExitHandler()
136+
137+
return await pRetry(
138+
async () => {
139+
try {
140+
// Check for stale lock and remove if necessary
141+
if (existsSync(lockPath) && this.isStale(lockPath, staleMs)) {
142+
logger.log(`Removing stale lock: ${lockPath}`)
143+
try {
144+
safeDeleteSync(lockPath, { recursive: true })
145+
} catch {
146+
// Ignore errors removing stale lock - will retry
147+
}
148+
}
149+
150+
// Atomic lock acquisition via mkdir
151+
mkdirSync(lockPath, { recursive: false })
152+
153+
// Track lock for cleanup
154+
this.activeLocks.add(lockPath)
155+
156+
// Return release function
157+
return () => this.release(lockPath)
158+
} catch (error) {
159+
// Handle lock contention
160+
if (error instanceof Error && (error as any).code === 'EEXIST') {
161+
if (this.isStale(lockPath, staleMs)) {
162+
throw new Error(`Stale lock detected: ${lockPath}`)
163+
}
164+
throw new Error(`Lock already exists: ${lockPath}`)
165+
}
166+
throw error
167+
}
168+
},
169+
{
170+
retries,
171+
baseDelayMs,
172+
maxDelayMs,
173+
jitter: true,
174+
},
175+
)
176+
}
177+
178+
/**
179+
* Release a lock and remove from tracking.
180+
* Removes the lock directory and stops tracking it for exit cleanup.
181+
*
182+
* @param lockPath - Path to the lock directory
183+
*
184+
* @example
185+
* ```typescript
186+
* processLock.release('/tmp/my-lock')
187+
* ```
188+
*/
189+
release(lockPath: string): void {
190+
try {
191+
if (existsSync(lockPath)) {
192+
safeDeleteSync(lockPath, { recursive: true })
193+
}
194+
this.activeLocks.delete(lockPath)
195+
} catch (error) {
196+
logger.warn(
197+
`Failed to release lock ${lockPath}: ${error instanceof Error ? error.message : String(error)}`,
198+
)
199+
}
200+
}
201+
202+
/**
203+
* Execute a function with exclusive lock protection.
204+
* Automatically handles lock acquisition, execution, and cleanup.
205+
*
206+
* This is the recommended way to use process locks, as it guarantees
207+
* cleanup even if the callback throws an error.
208+
*
209+
* @param lockPath - Path to the lock directory
210+
* @param fn - Function to execute while holding the lock
211+
* @param options - Lock acquisition options
212+
* @returns Result of the callback function
213+
* @throws Error from callback or lock acquisition failure
214+
*
215+
* @example
216+
* ```typescript
217+
* const result = await processLock.withLock('/tmp/my-lock', async () => {
218+
* // Critical section
219+
* return someValue
220+
* })
221+
* ```
222+
*/
223+
async withLock<T>(
224+
lockPath: string,
225+
fn: () => Promise<T>,
226+
options?: ProcessLockOptions,
227+
): Promise<T> {
228+
const release = await this.acquire(lockPath, options)
229+
try {
230+
return await fn()
231+
} finally {
232+
release()
233+
}
234+
}
235+
}
236+
237+
// Export singleton instance.
238+
export const processLock = new ProcessLockManager()

0 commit comments

Comments
 (0)