(lockPath: string, opts: LockOptions = {})
| 33 | * can't be acquired within the retry budget. |
| 34 | */ |
| 35 | export async function acquireLock(lockPath: string, opts: LockOptions = {}): Promise<LockHandle> { |
| 36 | const retries = opts.retries ?? 50; |
| 37 | const intervalMs = opts.intervalMs ?? 100; |
| 38 | const staleMs = opts.staleMs ?? 30_000; |
| 39 | |
| 40 | for (let attempt = 0; attempt <= retries; attempt++) { |
| 41 | try { |
| 42 | const fh = await fs.open(lockPath, 'wx'); // O_CREAT|O_EXCL → fails if held |
| 43 | try { |
| 44 | await fh.writeFile(`${process.pid} ${new Date().toISOString()}`); |
| 45 | } finally { |
| 46 | await fh.close(); |
| 47 | } |
| 48 | let released = false; |
| 49 | return { |
| 50 | release: async () => { |
| 51 | if (released) return; |
| 52 | released = true; |
| 53 | await fs.unlink(lockPath).catch(() => {}); |
| 54 | }, |
| 55 | }; |
| 56 | } catch (err: any) { |
| 57 | if (err?.code !== 'EEXIST') throw err; |
| 58 | // Held by someone else — reclaim if stale, else wait and retry. |
| 59 | try { |
| 60 | const st = await fs.stat(lockPath); |
| 61 | if (Date.now() - st.mtimeMs > staleMs) { |
| 62 | await fs.unlink(lockPath).catch(() => {}); |
| 63 | continue; // retry immediately after reclaiming a stale lock |
| 64 | } |
| 65 | } catch { |
| 66 | /* lock vanished between open and stat — just retry */ |
| 67 | } |
| 68 | if (attempt < retries) await sleep(intervalMs); |
| 69 | } |
| 70 | } |
| 71 | throw new Error(`Could not acquire lock ${lockPath} after ${retries} retries (held by another process?)`); |
| 72 | } |
| 73 | |
| 74 | /** Run `fn` while holding `lockPath`; always releases, even on throw. */ |
| 75 | export async function withLock<T>(lockPath: string, fn: () => Promise<T>, opts?: LockOptions): Promise<T> { |
no test coverage detected