fix: race-proof writer-lock acquisition + machine-readable conflict through init
- acquireWriterLock now CLAIMS with an atomic create-exclusive write (O_EXCL) inside a bounded retry loop: two processes racing an absent lock can never both succeed (the old read-then-tmp-rename flow let the loser keep running unlocked, silently). An EEXIST loser re-evaluates and either throws loudly with the winner's details or performs a verified stale-takeover (re-read before unlink so a lock that changed hands mid-deliberation is never clobbered). Exhausted contention fails loudly instead of degrading into a lockless open. - BRAINY_WRITER_LOCKED passes through init() unwrapped: the error documents a machine-readable contract (err.code + err.lockInfo with the holder's pid/host/heartbeat), but init's blanket error wrapping stripped both, leaving consumers a message to regex against. - Two contract tests added: stale-foreign takeover installs OUR lock via the atomic claim; the conflict error carries code + lockInfo at the public init() surface.
This commit is contained in:
parent
e450e0eedf
commit
01a3b46ade
4 changed files with 188 additions and 49 deletions
|
|
@ -110,6 +110,49 @@ describe('Multi-process safety + read-only mode', () => {
|
|||
// Don't track `blocked` for afterEach cleanup since init failed.
|
||||
})
|
||||
|
||||
it('takes over a STALE foreign lock (dead PID + old heartbeat) and claims atomically', async () => {
|
||||
const { mkdirSync, writeFileSync, readFileSync } = await import('node:fs')
|
||||
const { join } = await import('node:path')
|
||||
const os = await import('node:os')
|
||||
mkdirSync(join(dir, 'locks'), { recursive: true })
|
||||
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString()
|
||||
writeFileSync(join(dir, 'locks', '_writer.lock'), JSON.stringify({
|
||||
pid: 999999999, // no such process — provably dead
|
||||
hostname: os.hostname(),
|
||||
startedAt: tenMinutesAgo,
|
||||
lastHeartbeat: tenMinutesAgo,
|
||||
version: '8.0.0',
|
||||
rootDir: dir
|
||||
}))
|
||||
|
||||
writer = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
|
||||
await writer.init() // stale takeover must succeed
|
||||
|
||||
const lock = JSON.parse(readFileSync(join(dir, 'locks', '_writer.lock'), 'utf-8'))
|
||||
expect(lock.pid).toBe(process.pid) // the atomic wx claim installed OUR lock
|
||||
})
|
||||
|
||||
it('the writer-locked error carries the machine-readable contract (code + lockInfo)', async () => {
|
||||
const { mkdirSync, writeFileSync } = await import('node:fs')
|
||||
const { join } = await import('node:path')
|
||||
const os = await import('node:os')
|
||||
mkdirSync(join(dir, 'locks'), { recursive: true })
|
||||
const otherPid = (process as any).ppid || 1
|
||||
writeFileSync(join(dir, 'locks', '_writer.lock'), JSON.stringify({
|
||||
pid: otherPid,
|
||||
hostname: os.hostname(),
|
||||
startedAt: new Date().toISOString(),
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
version: '8.7.0',
|
||||
rootDir: dir
|
||||
}))
|
||||
|
||||
const blocked = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
|
||||
const err: any = await blocked.init().catch((e) => e)
|
||||
expect(err.code).toBe('BRAINY_WRITER_LOCKED')
|
||||
expect(err.lockInfo?.pid).toBe(otherPid)
|
||||
})
|
||||
|
||||
it('allows a second in-process writer with a warning (same PID)', async () => {
|
||||
// Two Brainy instances in the same Node process: not the dangerous
|
||||
// cross-process case. Should succeed (with a console warning).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue