Skip to content

Commit 073146a

Browse files
committed
feat(process-lock): align with npm npx locking strategy
- Change stale timeout from 10s to 5s (matches npm npx) - Add periodic lock touching (2s interval) to prevent false stale detection - Use second-level granularity for mtime comparison (avoids APFS issues) - Add touch timer management with automatic cleanup on exit - Prevent timers from keeping process alive with unref() This aligns Socket's process locking with npm's npx implementation per npm/cli#8512, providing more robust inter-process synchronization for long-running operations.
1 parent 4201b36 commit 073146a

File tree

1 file changed

+96
-17
lines changed

1 file changed

+96
-17
lines changed

src/process-lock.ts

Lines changed: 96 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/**
22
* @fileoverview Process locking utilities with stale detection and exit cleanup.
33
* Provides cross-platform inter-process synchronization using file-system based locks.
4+
* Aligned with npm's npx locking strategy (5-second stale timeout, periodic touching).
45
*/
56

6-
import { existsSync, mkdirSync, statSync } from 'node:fs'
7+
import { existsSync, mkdirSync, statSync, utimesSync } from 'node:fs'
78

89
import { safeDeleteSync } from './fs'
910
import { logger } from './logger'
@@ -35,10 +36,17 @@ export interface ProcessLockOptions {
3536
/**
3637
* Stale lock timeout in milliseconds.
3738
* 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)
39+
* Aligned with npm's npx locking strategy (5 seconds).
40+
* @default 5000 (5 seconds)
4041
*/
4142
staleMs?: number | undefined
43+
44+
/**
45+
* Interval for touching lock file to keep it fresh in milliseconds.
46+
* Set to 0 to disable periodic touching.
47+
* @default 2000 (2 seconds)
48+
*/
49+
touchIntervalMs?: number | undefined
4250
}
4351

4452
/**
@@ -48,6 +56,7 @@ export interface ProcessLockOptions {
4856
*/
4957
class ProcessLockManager {
5058
private activeLocks = new Set<string>()
59+
private touchTimers = new Map<string, NodeJS.Timeout>()
5160
private exitHandlerRegistered = false
5261

5362
/**
@@ -60,24 +69,85 @@ class ProcessLockManager {
6069
}
6170

6271
onExit(() => {
72+
// Clear all touch timers.
73+
for (const timer of this.touchTimers.values()) {
74+
clearInterval(timer)
75+
}
76+
this.touchTimers.clear()
77+
78+
// Clean up all active locks.
6379
for (const lockPath of this.activeLocks) {
6480
try {
6581
if (existsSync(lockPath)) {
6682
safeDeleteSync(lockPath, { recursive: true })
6783
}
6884
} catch {
69-
// Ignore cleanup errors during exit
85+
// Ignore cleanup errors during exit.
7086
}
7187
}
7288
})
7389

7490
this.exitHandlerRegistered = true
7591
}
7692

93+
/**
94+
* Touch a lock file to update its mtime.
95+
* This prevents the lock from being detected as stale during long operations.
96+
*
97+
* @param lockPath - Path to the lock directory
98+
*/
99+
private touchLock(lockPath: string): void {
100+
try {
101+
if (existsSync(lockPath)) {
102+
const now = new Date()
103+
utimesSync(lockPath, now, now)
104+
}
105+
} catch (error) {
106+
logger.warn(
107+
`Failed to touch lock ${lockPath}: ${error instanceof Error ? error.message : String(error)}`,
108+
)
109+
}
110+
}
111+
112+
/**
113+
* Start periodic touching of a lock file.
114+
* Aligned with npm npx strategy to prevent false stale detection.
115+
*
116+
* @param lockPath - Path to the lock directory
117+
* @param intervalMs - Touch interval in milliseconds
118+
*/
119+
private startTouchTimer(lockPath: string, intervalMs: number): void {
120+
if (intervalMs <= 0 || this.touchTimers.has(lockPath)) {
121+
return
122+
}
123+
124+
const timer = setInterval(() => {
125+
this.touchLock(lockPath)
126+
}, intervalMs)
127+
128+
// Prevent timer from keeping process alive.
129+
timer.unref()
130+
131+
this.touchTimers.set(lockPath, timer)
132+
}
133+
134+
/**
135+
* Stop periodic touching of a lock file.
136+
*
137+
* @param lockPath - Path to the lock directory
138+
*/
139+
private stopTouchTimer(lockPath: string): void {
140+
const timer = this.touchTimers.get(lockPath)
141+
if (timer) {
142+
clearInterval(timer)
143+
this.touchTimers.delete(lockPath)
144+
}
145+
}
146+
77147
/**
78148
* 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.
149+
* Uses second-level granularity to avoid APFS floating-point precision issues.
150+
* Aligned with npm's npx locking strategy.
81151
*
82152
* @param lockPath - Path to the lock directory
83153
* @param staleMs - Stale timeout in milliseconds
@@ -90,8 +160,10 @@ class ProcessLockManager {
90160
}
91161

92162
const stats = statSync(lockPath)
93-
const age = Date.now() - stats.mtime.getTime()
94-
return age > staleMs
163+
// Use second-level granularity to avoid APFS issues.
164+
const ageSeconds = Math.floor((Date.now() - stats.mtime.getTime()) / 1000)
165+
const staleSeconds = Math.floor(staleMs / 1000)
166+
return ageSeconds > staleSeconds
95167
} catch {
96168
return false
97169
}
@@ -128,35 +200,39 @@ class ProcessLockManager {
128200
baseDelayMs = 100,
129201
maxDelayMs = 1000,
130202
retries = 3,
131-
staleMs = 10_000,
203+
staleMs = 5000,
204+
touchIntervalMs = 2000,
132205
} = options
133206

134-
// Ensure exit handler is registered before any lock acquisition
207+
// Ensure exit handler is registered before any lock acquisition.
135208
this.ensureExitHandler()
136209

137210
return await pRetry(
138211
async () => {
139212
try {
140-
// Check for stale lock and remove if necessary
213+
// Check for stale lock and remove if necessary.
141214
if (existsSync(lockPath) && this.isStale(lockPath, staleMs)) {
142215
logger.log(`Removing stale lock: ${lockPath}`)
143216
try {
144217
safeDeleteSync(lockPath, { recursive: true })
145218
} catch {
146-
// Ignore errors removing stale lock - will retry
219+
// Ignore errors removing stale lock - will retry.
147220
}
148221
}
149222

150-
// Atomic lock acquisition via mkdir
223+
// Atomic lock acquisition via mkdir.
151224
mkdirSync(lockPath, { recursive: false })
152225

153-
// Track lock for cleanup
226+
// Track lock for cleanup.
154227
this.activeLocks.add(lockPath)
155228

156-
// Return release function
229+
// Start periodic touching to prevent stale detection.
230+
this.startTouchTimer(lockPath, touchIntervalMs)
231+
232+
// Return release function.
157233
return () => this.release(lockPath)
158234
} catch (error) {
159-
// Handle lock contention
235+
// Handle lock contention.
160236
if (error instanceof Error && (error as any).code === 'EEXIST') {
161237
if (this.isStale(lockPath, staleMs)) {
162238
throw new Error(`Stale lock detected: ${lockPath}`)
@@ -177,7 +253,7 @@ class ProcessLockManager {
177253

178254
/**
179255
* Release a lock and remove from tracking.
180-
* Removes the lock directory and stops tracking it for exit cleanup.
256+
* Stops periodic touching and removes the lock directory.
181257
*
182258
* @param lockPath - Path to the lock directory
183259
*
@@ -187,6 +263,9 @@ class ProcessLockManager {
187263
* ```
188264
*/
189265
release(lockPath: string): void {
266+
// Stop periodic touching.
267+
this.stopTouchTimer(lockPath)
268+
190269
try {
191270
if (existsSync(lockPath)) {
192271
safeDeleteSync(lockPath, { recursive: true })

0 commit comments

Comments
 (0)