|
1 | 1 | /** |
2 | 2 | * @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. |
4 | 4 | * 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 |
5 | 41 | */ |
6 | 42 |
|
7 | 43 | import { existsSync, mkdirSync, statSync, utimesSync } from 'node:fs' |
@@ -232,14 +268,45 @@ class ProcessLockManager { |
232 | 268 | // Return release function. |
233 | 269 | return () => this.release(lockPath) |
234 | 270 | } 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') { |
237 | 275 | if (this.isStale(lockPath, staleMs)) { |
238 | 276 | throw new Error(`Stale lock detected: ${lockPath}`) |
239 | 277 | } |
240 | 278 | throw new Error(`Lock already exists: ${lockPath}`) |
241 | 279 | } |
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 | + }) |
243 | 310 | } |
244 | 311 | }, |
245 | 312 | { |
|
0 commit comments