diff --git a/packages/opencode/src/core/refresh-file-lock.ts b/packages/opencode/src/core/refresh-file-lock.ts index 5899c22..0fec73a 100644 --- a/packages/opencode/src/core/refresh-file-lock.ts +++ b/packages/opencode/src/core/refresh-file-lock.ts @@ -27,7 +27,14 @@ export async function acquireRefreshFileLock(options: { | 'stale-marker-stat' | 'stale-marker-claimed' | 'stale-lock-confirmed' - | 'eviction-marker-acquired', + | 'eviction-marker-acquired' + | 'renewal-owner-confirmed' + | 'renewal-marker-unavailable' + | 'renewal-write-fenced' + | 'renewal-write-ready' + | 'relinquish-read' + | 'renewal-finished' + | 'release-owner-confirmed', ) => void | Promise }): Promise<{ release: () => Promise } | null> { const accountPath = options.path ?? getAccountStoragePath() @@ -37,6 +44,18 @@ export async function acquireRefreshFileLock(options: { const now = options.now ?? Date.now let renewTimer: ReturnType | null = null let released = false + let renewalInFlight: Promise | null = null + // Fencing-token eviction: a directory-based marker (mkdir O_EXCL) serializes + // destructive removal and generation-sensitive owner mutations. The marker + // holds an owner file so ownership survives a stale-marker recovery rename: + // the recovering contender renames the stale directory, then must re-check + // ownership before acting — preventing the 3rd interleaving where a stale + // observer renames the FRESH marker the mkdir-winner created. + const evictPath = `${lockPath}.evicting` + const evictOwnerPath = join(evictPath, 'owner.json') + const evictOwnerId = randomUUID() + const EVICT_TTL = 5_000 + const MAX_STEAL_ATTEMPTS = 8 async function readOwner() { try { @@ -87,105 +106,197 @@ export async function acquireRefreshFileLock(options: { } } - function scheduleRenewal() { - if (!options.renew || released) return - const intervalMs = - options.renewIntervalMs ?? Math.max(1_000, Math.floor(options.ttlMs / 3)) - renewTimer = setRefreshLockRenewalTimeout(() => { - void (async () => { - try { - const owner = await readOwner() - const currentNow = now() - if ( - released || - owner?.ownerId !== ownerId || - Number(owner?.expiresAt) <= currentNow - ) { - return - } - await writeOwner() - scheduleRenewal() - } catch { - // If renewal fails, contenders will wait until the last written expiry. - } - })() - }, intervalMs) - if ('unref' in renewTimer) renewTimer.unref() + async function backoff() { + await new Promise((resolve) => + setTimeout(resolve, Math.floor(Math.random() * 4)), + ) } - let acquired = await tryAcquire() - if (!acquired) { - // Fencing-token eviction: a directory-based marker (mkdir O_EXCL) serializes - // destructive removal to one contender at a time. The marker holds an owner - // file so ownership survives a stale-marker recovery rename: the recovering - // contender renames the stale directory, then must re-check ownership before - // acting — preventing the 3rd interleaving where a stale observer renames the - // FRESH marker the mkdir-winner created. - const evictPath = `${lockPath}.evicting` - const evictOwnerPath = join(evictPath, 'owner.json') - const evictOwnerId = randomUUID() - const EVICT_TTL = 5_000 - const MAX_STEAL_ATTEMPTS = 8 + async function lockIsLive() { + try { + const currentOwner = await readOwner() + return Number(currentOwner?.expiresAt) > now() + } catch { + try { + const current = await stat(lockPath) + return current.mtimeMs + options.ttlMs > now() + } catch { + // Lock doesn't exist — safe to acquire. + return false + } + } + } + + // Fail-closed: any read error means we do NOT own the marker. + async function ownsEvictionMarker() { + try { + const owner = JSON.parse(await readFile(evictOwnerPath, 'utf8')) + return owner?.ownerId === evictOwnerId + } catch { + return false + } + } - async function backoff() { - await new Promise((resolve) => - setTimeout(resolve, Math.floor(Math.random() * 4)), + async function releaseEvictionMarker() { + if (await ownsEvictionMarker()) { + await rm(evictPath, { recursive: true, force: true }).catch(() => {}) + } + } + + async function tryAcquireEvictionMarker() { + await mkdir(evictPath) + try { + await writeFile( + evictOwnerPath, + `${JSON.stringify({ ownerId: evictOwnerId, createdAt: now() })}\n`, + { encoding: 'utf8', mode: 0o600, flag: 'wx' }, ) + } catch (error) { + // A competing contender can rename our just-created marker directory + // away between the mkdir above and this write (the stale-marker steal + // path below does exactly that). That is a lost race, not a failure, so + // report it as such and let the caller back off and retry rather than + // failing the whole lock acquisition. + if (isLostMarkerRaceError(error)) return false + await releaseEvictionMarker() + throw error } + if (options.onStep) await options.onStep('eviction-marker-acquired') + return true + } - async function lockIsLive() { - try { - const currentOwner = await readOwner() - return Number(currentOwner?.expiresAt) > now() - } catch { - try { - const current = await stat(lockPath) - return current.mtimeMs + options.ttlMs > now() - } catch { - // Lock doesn't exist — safe to acquire. - return false - } - } + async function recoverStaleEvictionMarker(): Promise< + 'fresh' | 'missing' | 'recovered' + > { + let evictStat: Awaited> + try { + evictStat = await stat(evictPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing' + throw error + } + if (evictStat.mtimeMs + EVICT_TTL > now()) return 'fresh' + + if (options.onStep) await options.onStep('stale-marker-stat') + const claimedPath = `${evictPath}.${randomUUID()}` + try { + await rename(evictPath, claimedPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing' + throw error + } + if (options.onStep) await options.onStep('stale-marker-claimed') + await rm(claimedPath, { recursive: true, force: true }).catch(() => {}) + return 'recovered' + } + + async function withEvictionMarker(action: () => Promise) { + try { + if (!(await tryAcquireEvictionMarker())) return false + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false + throw error } - // Fail-closed: any read error means we do NOT own the marker. - async function ownsEvictionMarker() { + try { + await action() + } finally { + await releaseEvictionMarker() + } + return true + } + + // Marker loss after a write may mean our record replaced a successor's. + // Delete only a record still owned by us; a concurrent successor write can + // then yield zero winners, never two. + async function relinquishLockAfterMarkerLoss() { + for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) { + if (options.onStep) await options.onStep('relinquish-read') + let owner: { ownerId?: string } | undefined try { - const owner = JSON.parse(await readFile(evictOwnerPath, 'utf8')) - return owner?.ownerId === evictOwnerId + owner = await readOwner() } catch { - return false + return } - } - - async function tryAcquireEvictionMarker() { - await mkdir(evictPath) + if (owner?.ownerId !== ownerId) return try { - await writeFile( - evictOwnerPath, - `${JSON.stringify({ ownerId: evictOwnerId, createdAt: now() })}\n`, - { encoding: 'utf8', mode: 0o600, flag: 'wx' }, - ) - } catch (error) { - // A competing contender can rename our just-created marker directory - // away between the mkdir above and this write (the stale-marker steal - // path below does exactly that). That is a lost race, not a failure, so - // report it as such and let the caller back off and retry rather than - // failing the whole lock acquisition. - if (isLostMarkerRaceError(error)) return false - await releaseEvictionMarker() - throw error + await rm(lockPath, { recursive: true, force: true }) + return + } catch { + await backoff() } - await options.onStep?.('eviction-marker-acquired') - return true } + } - async function releaseEvictionMarker() { - if (await ownsEvictionMarker()) { - await rm(evictPath, { recursive: true, force: true }).catch(() => {}) - } - } + function scheduleRenewal() { + if (!options.renew || released) return + const intervalMs = + options.renewIntervalMs ?? Math.max(1_000, Math.floor(options.ttlMs / 3)) + renewTimer = setRefreshLockRenewalTimeout(() => { + const renewal = (async () => { + let shouldReschedule = !released + try { + const markerAcquired = await withEvictionMarker(async () => { + const owner = await readOwner() + const currentNow = now() + if (released || owner?.ownerId !== ownerId) { + shouldReschedule = false + return + } + // An expired lease is no longer ours to extend; a contender may + // already be eligible to acquire it. + if (Number(owner?.expiresAt) <= currentNow) { + shouldReschedule = false + return + } + if (options.onStep) await options.onStep('renewal-owner-confirmed') + if (released) { + shouldReschedule = false + return + } + if (!(await ownsEvictionMarker())) return + if (options.onStep) await options.onStep('renewal-write-fenced') + if (released) { + shouldReschedule = false + return + } + if (!(await ownsEvictionMarker())) return + if (options.onStep) await options.onStep('renewal-write-ready') + await writeOwner() + if (!(await ownsEvictionMarker())) { + // Marker read errors fail closed: prompt relinquish avoids ambiguity + // instead of waiting for TTL; both outcomes keep zero or one winner. + shouldReschedule = false + await relinquishLockAfterMarkerLoss() + return + } + }) + if (!markerAcquired && options.onStep) { + await options.onStep('renewal-marker-unavailable') + } + } catch { + // Transient marker and filesystem failures retry on the next interval. + } finally { + if (options.onStep) { + try { + await options.onStep('renewal-finished') + } catch { + // Test seams must not turn an otherwise-safe renewal into a rejection. + } + } + if (shouldReschedule && !released) scheduleRenewal() + } + })() + renewalInFlight = renewal + void renewal.finally(() => { + if (renewalInFlight === renewal) renewalInFlight = null + }) + }, intervalMs) + if ('unref' in renewTimer) renewTimer.unref() + } + let acquired = await tryAcquire() + if (!acquired) { for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) { acquired = await tryAcquire() if (acquired) break @@ -200,31 +311,8 @@ export async function acquireRefreshFileLock(options: { const code = (evictError as NodeJS.ErrnoException).code if (code !== 'EEXIST') throw evictError - let evictStat: Awaited> - try { - evictStat = await stat(evictPath) - } catch (statError) { - if ((statError as NodeJS.ErrnoException).code === 'ENOENT') { - await backoff() - continue - } - throw statError - } - if (evictStat.mtimeMs + EVICT_TTL > now()) return null - - await options.onStep?.('stale-marker-stat') - const claimedPath = `${evictPath}.${randomUUID()}` - try { - await rename(evictPath, claimedPath) - } catch (renameError) { - if ((renameError as NodeJS.ErrnoException).code === 'ENOENT') { - await backoff() - continue - } - throw renameError - } - await options.onStep?.('stale-marker-claimed') - await rm(claimedPath, { recursive: true, force: true }).catch(() => {}) + const recovered = await recoverStaleEvictionMarker() + if (recovered === 'fresh') return null await backoff() continue } @@ -234,7 +322,7 @@ export async function acquireRefreshFileLock(options: { // Fence check 1: verify we still own the marker before acting on the // stale-lock-confirmed decision. if (!(await ownsEvictionMarker())) return null - await options.onStep?.('stale-lock-confirmed') + if (options.onStep) await options.onStep('stale-lock-confirmed') // Fence check 2: re-verify ownership after the seam (another contender // may have renamed our fresh marker while we were paused here). if (!(await ownsEvictionMarker())) return null @@ -269,13 +357,25 @@ export async function acquireRefreshFileLock(options: { clearRefreshLockRenewalTimeout(renewTimer) renewTimer = null } - try { - const owner = await readOwner() - if (owner?.ownerId !== ownerId) return - } catch { - return + await renewalInFlight + for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) { + try { + const markerAcquired = await withEvictionMarker(async () => { + const owner = await readOwner() + if (owner?.ownerId !== ownerId) return + if (options.onStep) await options.onStep('release-owner-confirmed') + if (!(await ownsEvictionMarker())) return + await rm(lockPath, { recursive: true, force: true }).catch(() => {}) + }) + if (markerAcquired) return + await recoverStaleEvictionMarker() + } catch { + return + } + await backoff() } - await rm(lockPath, { recursive: true, force: true }).catch(() => {}) + // Do not delete by pathname without the marker: bounded retries leave the + // lease to expire rather than risking removal of a successor's lock. }, } } diff --git a/packages/opencode/src/tests/refresh-file-lock.test.ts b/packages/opencode/src/tests/refresh-file-lock.test.ts index 552c990..71cbafb 100644 --- a/packages/opencode/src/tests/refresh-file-lock.test.ts +++ b/packages/opencode/src/tests/refresh-file-lock.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { acquireRefreshFileLock } from '../core/refresh-file-lock.ts' @@ -14,6 +15,45 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) +function deferred() { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +async function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out after ${ms}ms`)), + ms, + ) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +async function resolvesWithin(promise: Promise, ms: number) { + return await Promise.race([ + promise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), ms)), + ]) +} + +async function readLockOwner(lockPath: string) { + return JSON.parse(await readFile(lockPath, 'utf8')) as { + ownerId: string + expiresAt: number + } +} + describe('acquireRefreshFileLock', () => { it('creates a missing parent directory before acquiring the lock', async () => { const path = join(dir, 'missing-sub', 'state.json') @@ -53,4 +93,403 @@ describe('acquireRefreshFileLock', () => { expect(retry).not.toBeNull() await retry?.release() }) + + it('does not let a stalled renewal overwrite a successor that stole its marker', async () => { + const path = join(dir, 'renewal-race.json') + const name = 'renewal-race' + const lockPath = `${path}.${name}.lock` + const renewalConfirmed = deferred() + const releaseRenewal = deferred() + const renewalFinished = deferred() + const start = Date.now() + let currentNow = start + + const first = await acquireRefreshFileLock({ + name, + path, + ttlMs: 100, + now: () => currentNow, + renew: true, + renewIntervalMs: 1, + onStep: async (step) => { + if (step === 'renewal-owner-confirmed') { + renewalConfirmed.resolve() + await releaseRenewal.promise + } + if (step === 'renewal-finished') renewalFinished.resolve() + }, + }) + expect(first).not.toBeNull() + + await withTimeout(renewalConfirmed.promise, 1_000) + currentNow = start + 10_000 + const successor = await acquireRefreshFileLock({ + name, + path, + ttlMs: 100, + now: () => currentNow, + }) + expect(successor).not.toBeNull() + const successorOwner = await readLockOwner(lockPath) + + releaseRenewal.resolve() + await withTimeout(renewalFinished.promise, 1_000) + + expect(await readLockOwner(lockPath)).toEqual(successorOwner) + await first?.release() + await successor?.release() + }) + + it('does not let a stalled release remove a successor that stole its marker', async () => { + const path = join(dir, 'release-race.json') + const name = 'release-race' + const lockPath = `${path}.${name}.lock` + const releaseConfirmed = deferred() + const releaseRemoval = deferred() + const start = Date.now() + let currentNow = start + + const first = await acquireRefreshFileLock({ + name, + path, + ttlMs: 100, + now: () => currentNow, + onStep: async (step) => { + if (step === 'release-owner-confirmed') { + releaseConfirmed.resolve() + await releaseRemoval.promise + } + }, + }) + expect(first).not.toBeNull() + + const firstRelease = first!.release() + await withTimeout(releaseConfirmed.promise, 1_000) + currentNow = start + 10_000 + const successor = await acquireRefreshFileLock({ + name, + path, + ttlMs: 100, + now: () => currentNow, + }) + expect(successor).not.toBeNull() + const successorOwner = await readLockOwner(lockPath) + + releaseRemoval.resolve() + await firstRelease + + expect(existsSync(lockPath)).toBe(true) + expect(await readLockOwner(lockPath)).toEqual(successorOwner) + await successor?.release() + }) + + it('waits for an in-flight renewal before release can remove the lock', async () => { + const path = join(dir, 'release-renewal-race.json') + const name = 'release-renewal-race' + const lockPath = `${path}.${name}.lock` + const renewalWriteFenced = deferred() + const releaseRenewal = deferred() + const renewalFinished = deferred() + const currentNow = Date.now() + + const first = await acquireRefreshFileLock({ + name, + path, + ttlMs: 100, + now: () => currentNow, + renew: true, + renewIntervalMs: 1, + onStep: async (step) => { + if (step === 'renewal-write-fenced') { + renewalWriteFenced.resolve() + await releaseRenewal.promise + } + if (step === 'renewal-finished') renewalFinished.resolve() + }, + }) + expect(first).not.toBeNull() + + await withTimeout(renewalWriteFenced.promise, 1_000) + const release = first!.release() + releaseRenewal.resolve() + await withTimeout(renewalFinished.promise, 1_000) + await release + + expect(existsSync(lockPath)).toBe(false) + }) + + it('re-checks ownership after the renewal write seam before writing', async () => { + const path = join(dir, 'renewal-write-seam-race.json') + const name = 'renewal-write-seam-race' + const lockPath = `${path}.${name}.lock` + const renewalWriteFenced = deferred() + const releaseRenewal = deferred() + const renewalFinished = deferred() + const start = Date.now() + let currentNow = start + + const first = await acquireRefreshFileLock({ + name, + path, + ttlMs: 100, + now: () => currentNow, + renew: true, + renewIntervalMs: 1, + onStep: async (step) => { + if (step === 'renewal-write-fenced') { + renewalWriteFenced.resolve() + await releaseRenewal.promise + } + if (step === 'renewal-finished') renewalFinished.resolve() + }, + }) + expect(first).not.toBeNull() + + await withTimeout(renewalWriteFenced.promise, 1_000) + currentNow = start + 10_000 + const successor = await acquireRefreshFileLock({ + name, + path, + ttlMs: 100, + now: () => currentNow, + }) + expect(successor).not.toBeNull() + const successorOwner = await readLockOwner(lockPath) + + releaseRenewal.resolve() + await withTimeout(renewalFinished.promise, 1_000) + + expect(await readLockOwner(lockPath)).toEqual(successorOwner) + await first?.release() + await successor?.release() + }) + + it('relinquishes the lock when its marker is stolen after the final renewal check', async () => { + const path = join(dir, 'renewal-post-write-race.json') + const name = 'renewal-post-write-race' + const lockPath = `${path}.${name}.lock` + const renewalWriteReady = deferred() + const releaseRenewal = deferred() + const renewalFinished = deferred() + const start = Date.now() + let currentNow = start + + const first = await acquireRefreshFileLock({ + name, + path, + ttlMs: 100, + now: () => currentNow, + renew: true, + renewIntervalMs: 1, + onStep: async (step) => { + if (step === 'renewal-write-ready') { + renewalWriteReady.resolve() + await releaseRenewal.promise + } + if (step === 'renewal-finished') renewalFinished.resolve() + }, + }) + expect(first).not.toBeNull() + + await withTimeout(renewalWriteReady.promise, 1_000) + currentNow = start + 10_000 + const successor = await acquireRefreshFileLock({ + name, + path, + ttlMs: 100, + now: () => currentNow, + }) + expect(successor).not.toBeNull() + + releaseRenewal.resolve() + await withTimeout(renewalFinished.promise, 1_000) + + expect(existsSync(lockPath)).toBe(false) + await first?.release() + await successor?.release() + }) + + it('preserves a successor record during post-write relinquish', async () => { + const path = join(dir, 'renewal-relinquish-successor.json') + const name = 'renewal-relinquish-successor' + const lockPath = `${path}.${name}.lock` + const renewalWriteReady = deferred() + const relinquishRead = deferred() + const allowRelinquishRead = deferred() + const releaseRenewal = deferred() + const renewalFinished = deferred() + const start = Date.now() + let currentNow = start + + const first = await acquireRefreshFileLock({ + name, + path, + ttlMs: 100, + now: () => currentNow, + renew: true, + renewIntervalMs: 1, + onStep: async (step) => { + if (step === 'renewal-write-ready') { + renewalWriteReady.resolve() + await releaseRenewal.promise + } + if (step === 'relinquish-read') { + relinquishRead.resolve() + await allowRelinquishRead.promise + } + if (step === 'renewal-finished') renewalFinished.resolve() + }, + }) + expect(first).not.toBeNull() + + await withTimeout(renewalWriteReady.promise, 1_000) + currentNow = start + 10_000 + const successor = await acquireRefreshFileLock({ + name, + path, + ttlMs: 100, + now: () => currentNow, + }) + expect(successor).not.toBeNull() + const successorOwner = await readLockOwner(lockPath) + + releaseRenewal.resolve() + await withTimeout(relinquishRead.promise, 1_000) + await writeFile(lockPath, `${JSON.stringify(successorOwner)}\n`, { + encoding: 'utf8', + mode: 0o600, + }) + allowRelinquishRead.resolve() + await withTimeout(renewalFinished.promise, 1_000) + + expect(existsSync(lockPath)).toBe(true) + expect(await readLockOwner(lockPath)).toEqual(successorOwner) + await first?.release() + await successor?.release() + }) + + it('reschedules after marker contention and advances the lease', async () => { + const path = join(dir, 'renewal-contention.json') + const name = 'renewal-contention' + const lockPath = `${path}.${name}.lock` + const markerPath = `${lockPath}.evicting` + const markerUnavailable = deferred() + const renewed = deferred() + const start = Date.now() + let currentNow = start + let sawRenewalWrite = false + + const lock = await acquireRefreshFileLock({ + name, + path, + ttlMs: 10_000, + now: () => currentNow, + renew: true, + renewIntervalMs: 10, + onStep: (step) => { + if (step === 'renewal-marker-unavailable') markerUnavailable.resolve() + if (step === 'renewal-write-fenced') sawRenewalWrite = true + if (step === 'renewal-finished' && sawRenewalWrite) renewed.resolve() + }, + }) + expect(lock).not.toBeNull() + const before = await readLockOwner(lockPath) + await mkdir(markerPath) + + await withTimeout(markerUnavailable.promise, 1_000) + currentNow = start + 100 + await rm(markerPath, { recursive: true, force: true }) + expect(await resolvesWithin(renewed.promise, 500)).toBe(true) + + const after = await readLockOwner(lockPath) + expect(after.ownerId).toBe(before.ownerId) + expect(after.expiresAt).toBeGreaterThan(before.expiresAt) + await lock?.release() + }) + + it('reschedules after a renewal marker failure throws', async () => { + const path = join(dir, 'renewal-throw.json') + const name = 'renewal-throw' + const lockPath = `${path}.${name}.lock` + const injectedFailure = deferred() + const renewed = deferred() + const start = Date.now() + let currentNow = start + let injected = false + let sawRenewalWrite = false + + const lock = await acquireRefreshFileLock({ + name, + path, + ttlMs: 10_000, + now: () => currentNow, + renew: true, + renewIntervalMs: 10, + onStep: (step) => { + if (step === 'renewal-owner-confirmed' && !injected) { + injected = true + injectedFailure.resolve() + throw new Error('injected renewal marker failure') + } + if (step === 'renewal-write-fenced') sawRenewalWrite = true + if (step === 'renewal-finished' && sawRenewalWrite) renewed.resolve() + }, + }) + expect(lock).not.toBeNull() + const before = await readLockOwner(lockPath) + + await withTimeout(injectedFailure.promise, 1_000) + currentNow = start + 100 + expect(await resolvesWithin(renewed.promise, 500)).toBe(true) + + const after = await readLockOwner(lockPath) + expect(after.ownerId).toBe(before.ownerId) + expect(after.expiresAt).toBeGreaterThan(before.expiresAt) + await lock?.release() + }) + + it('retries release after recovering a stale marker', async () => { + const path = join(dir, 'release-stale-marker.json') + const name = 'release-stale-marker' + const lockPath = `${path}.${name}.lock` + const markerPath = `${lockPath}.evicting` + const lock = await acquireRefreshFileLock({ name, path, ttlMs: 10_000 }) + expect(lock).not.toBeNull() + + await mkdir(markerPath) + await writeFile( + join(markerPath, 'owner.json'), + `${JSON.stringify({ ownerId: 'stale-marker', createdAt: 0 })}\n`, + { encoding: 'utf8', mode: 0o600 }, + ) + const staleAt = new Date(Date.now() - 10_000) + await utimes(markerPath, staleAt, staleAt) + + await lock?.release() + + expect(existsSync(lockPath)).toBe(false) + }) + + it('elects one owner across 512 plain stale-lock contentions', async () => { + // Deterministic seam tests cover the race proofs; this is ordinary contention smoke. + const path = join(dir, 'plain-contention.json') + const name = 'plain-contention' + const lockPath = `${path}.${name}.lock` + + for (let round = 0; round < 512; round++) { + await writeFile( + lockPath, + `${JSON.stringify({ ownerId: 'stale-owner', expiresAt: 0 })}\n`, + { encoding: 'utf8', mode: 0o600 }, + ) + const contenders = await Promise.all([ + acquireRefreshFileLock({ name, path, ttlMs: 1_000 }), + acquireRefreshFileLock({ name, path, ttlMs: 1_000 }), + ]) + const winners = contenders.filter((lock) => lock !== null) + + expect(winners).toHaveLength(1) + await winners[0]?.release() + } + }) })