( maxAttempts: number = 5, delayIncrementMs: number = 200 )
| 226 | * Returns the file handle to hold for the runner's lifetime, or null if locked. |
| 227 | */ |
| 228 | export async function acquireRunnerLock( |
| 229 | maxAttempts: number = 5, |
| 230 | delayIncrementMs: number = 200 |
| 231 | ): Promise<FileHandle | null> { |
| 232 | for (let attempt = 1; attempt <= maxAttempts; attempt++) { |
| 233 | try { |
| 234 | // 'wx' ensures we only create if it doesn't exist (atomic lock acquisition) |
| 235 | const fileHandle = await open(configuration.runnerLockFile, 'wx'); |
| 236 | // Write PID to lock file for debugging |
| 237 | await fileHandle.writeFile(String(process.pid)); |
| 238 | return fileHandle; |
| 239 | } catch (error: any) { |
| 240 | if (error.code === 'EEXIST') { |
| 241 | // Lock file exists, check if process is still running |
| 242 | try { |
| 243 | const lockPid = readFileSync(configuration.runnerLockFile, 'utf-8').trim(); |
| 244 | if (lockPid && !isNaN(Number(lockPid))) { |
| 245 | if (!isProcessAlive(Number(lockPid))) { |
| 246 | // Process doesn't exist, remove stale lock |
| 247 | unlinkSync(configuration.runnerLockFile); |
| 248 | continue; // Retry acquisition |
| 249 | } |
| 250 | } |
| 251 | } catch { |
| 252 | // Can't read lock file, might be corrupted |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | if (attempt === maxAttempts) { |
| 257 | return null; |
| 258 | } |
| 259 | const delayMs = attempt * delayIncrementMs; |
| 260 | await new Promise(resolve => setTimeout(resolve, delayMs)); |
| 261 | } |
| 262 | } |
| 263 | return null; |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * Release runner lock by closing handle and deleting lock file |
no test coverage detected