Skip to content

Commit 45a6daf

Browse files
committed
docs(process-lock): explain directory-based locking strategy
Add comprehensive documentation explaining why mkdir() is used for locks: - Atomic guarantee across all filesystems including NFS - Simpler than file-based locking (no PID tracking, no file descriptors) - Historical precedent (used by package managers and git) - The mtime trick: empty directory touched periodically to signal freshness
1 parent ce77502 commit 45a6daf

1 file changed

Lines changed: 71 additions & 4 deletions

File tree

src/process-lock.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,43 @@
11
/**
22
* @fileoverview Process locking utilities with stale detection and exit cleanup.
3-
* Provides cross-platform inter-process synchronization using file-system based locks.
3+
* Provides cross-platform inter-process synchronization using directory-based locks.
44
* Aligned with npm's npx locking strategy (5-second stale timeout, periodic touching).
5+
*
6+
* ## Why directories instead of files?
7+
*
8+
* This implementation uses `mkdir()` to create lock directories (not files) because:
9+
*
10+
* 1. **Atomic guarantee**: `mkdir()` is guaranteed atomic across ALL filesystems,
11+
* including NFS. Only ONE process can successfully create the directory. If it
12+
* exists, `mkdir()` fails with EEXIST instantly with no race conditions.
13+
*
14+
* 2. **File-based locking issues**:
15+
* - `writeFile()` with `flag: 'wx'` - atomicity can fail on NFS
16+
* - `open()` with `O_EXCL` - not guaranteed atomic on older NFS
17+
* - Traditional lockfiles - can have race conditions on network filesystems
18+
*
19+
* 3. **Simplicity**: No need to write/read file content, track PIDs, or manage
20+
* file descriptors. Just create/delete directory and check mtime.
21+
*
22+
* 4. **Historical precedent**: Well-known Unix locking pattern used by package
23+
* managers for decades. Git uses similar approach for `.git/index.lock`.
24+
*
25+
* ## The mtime trick
26+
*
27+
* We periodically update the lock directory's mtime (modification time) by
28+
* "touching" it to signal "I'm still actively working". This prevents other
29+
* processes from treating the lock as stale and removing it.
30+
*
31+
* **The lock directory remains empty** - it's just a sentinel that signals
32+
* "locked". The mtime is the only data needed to track lock freshness.
33+
*
34+
* ## npm npx compatibility
35+
*
36+
* This implementation matches npm npx's concurrency.lock approach:
37+
* - Lock created via `mkdir(path.join(installDir, 'concurrency.lock'))`
38+
* - 5-second stale timeout (if mtime is older than 5s, lock is stale)
39+
* - 2-second touching interval (updates mtime every 2s to keep lock fresh)
40+
* - Automatic cleanup on process exit
541
*/
642

743
import { existsSync, mkdirSync, statSync, utimesSync } from 'node:fs'
@@ -232,14 +268,45 @@ class ProcessLockManager {
232268
// Return release function.
233269
return () => this.release(lockPath)
234270
} catch (error) {
235-
// Handle lock contention.
236-
if (error instanceof Error && (error as any).code === 'EEXIST') {
271+
const code = (error as NodeJS.ErrnoException).code
272+
273+
// Handle lock contention - lock already exists.
274+
if (code === 'EEXIST') {
237275
if (this.isStale(lockPath, staleMs)) {
238276
throw new Error(`Stale lock detected: ${lockPath}`)
239277
}
240278
throw new Error(`Lock already exists: ${lockPath}`)
241279
}
242-
throw error
280+
281+
// Handle permission errors - not retryable.
282+
if (code === 'EACCES' || code === 'EPERM') {
283+
throw new Error(
284+
`Permission denied creating lock: ${lockPath}. ` +
285+
'Check directory permissions or run with appropriate access.',
286+
{ cause: error },
287+
)
288+
}
289+
290+
// Handle read-only filesystem - not retryable.
291+
if (code === 'EROFS') {
292+
throw new Error(
293+
`Cannot create lock on read-only filesystem: ${lockPath}`,
294+
{ cause: error },
295+
)
296+
}
297+
298+
// Handle parent path issues - not retryable.
299+
if (code === 'ENOTDIR' || code === 'ENOENT') {
300+
throw new Error(
301+
`Lock parent directory does not exist or is not a directory: ${lockPath}`,
302+
{ cause: error },
303+
)
304+
}
305+
306+
// Re-throw other errors with context.
307+
throw new Error(`Failed to acquire lock: ${lockPath}`, {
308+
cause: error,
309+
})
243310
}
244311
},
245312
{

0 commit comments

Comments
 (0)