Skip to content

Commit 9e85576

Browse files
committed
feat(process-lock): add general-purpose inter-process locking
Add process-lock module for cross-platform inter-process synchronization using file-system based locks. Inspired by npm's withLock mechanism. Features: - Atomic lock acquisition using mkdir (POSIX standard) - Stale lock detection and cleanup (10 second default, aligned with npm) - Automatic process exit cleanup - Exponential backoff retry strategy - Cross-platform network filesystem compatibility Includes comprehensive unit tests covering: - Lock acquisition and release - Stale lock handling - Concurrent operations - Error handling - withLock convenience method
1 parent 98c25a0 commit 9e85576

4 files changed

Lines changed: 621 additions & 1 deletion

File tree

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
"useNumberNamespace": "error",
7979
"noInferrableTypes": "off",
8080
"noUselessElse": "error",
81-
"useNumericSeparators": "error"
81+
"useNumericSeparators": "off"
8282
},
8383
"suspicious": {
8484
"noExplicitAny": "off",

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

0 commit comments

Comments
 (0)