fix(settings-local): never steal writer locks
This commit is contained in:
@@ -70,25 +70,20 @@ describe('writer lock', () => {
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 7')
|
||||
})
|
||||
|
||||
it('breaks a stale writer lock with a warning and writes through', async () => {
|
||||
it('does not steal an old writer lock', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'alpha:\n value: 4\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
await writeFile(`${path}.lock`, 'crashed-holder\n')
|
||||
const lockPath = `${path}.lock`
|
||||
await writeFile(lockPath, 'slow-holder\n')
|
||||
const past = (Date.now() - 60_000) / 1000
|
||||
await utimes(`${path}.lock`, past, past)
|
||||
await scope.update({ value: 9 })
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 9')
|
||||
})
|
||||
await utimes(lockPath, past, past)
|
||||
|
||||
it('times out on a lock a live holder never releases', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
await writeFile(`${path}.lock`, 'busy-holder\n')
|
||||
await expect(scope.update({ value: 1 })).rejects.toThrow(/timed out waiting for the writer lock/)
|
||||
await expect(scope.update({ value: 9 })).rejects.toThrow(/timed out waiting for the writer lock/)
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 4')
|
||||
expect(await readFile(lockPath, 'utf8')).toBe('slow-holder\n')
|
||||
}, 10_000)
|
||||
|
||||
it('surfaces a non-contention lock failure as the write error', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Writer-lock races that cannot be timed from outside: a contender whose lock
|
||||
// vanishes between the failed exclusive create and the stat, a stat failing
|
||||
// for a reason other than absence, and a temp-file write failing mid-cycle.
|
||||
// vanishes after the failed exclusive create and a temp-file write failing
|
||||
// mid-cycle.
|
||||
// The fs/promises seam is partially mocked to inject exactly one failure at a
|
||||
// chosen path suffix; everything else passes through to the real filesystem.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -14,27 +14,23 @@ import { SettingsLocal } from '../src/index.ts'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
/** One-shot failure injections keyed by operation, matched on a path suffix. */
|
||||
failures: [] as Array<{ op: 'writeFile' | 'stat'; suffix: string; code: string }>,
|
||||
failures: [] as Array<{ suffix: string; code: string }>,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
const inject = (op: 'writeFile' | 'stat', path: unknown): void => {
|
||||
const index = state.failures.findIndex(f => f.op === op && String(path).endsWith(f.suffix))
|
||||
const inject = (path: unknown): void => {
|
||||
const index = state.failures.findIndex(f => String(path).endsWith(f.suffix))
|
||||
if (index === -1) return
|
||||
const [failure] = state.failures.splice(index, 1)
|
||||
throw Object.assign(new Error(`${failure!.code}: injected ${op} failure`), { code: failure!.code })
|
||||
throw Object.assign(new Error(`${failure!.code}: injected writeFile failure`), { code: failure!.code })
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
writeFile: (async (path: unknown, ...rest: never[]) => {
|
||||
inject('writeFile', path)
|
||||
inject(path)
|
||||
return (actual.writeFile as (path: unknown, ...args: never[]) => Promise<void>)(path, ...rest)
|
||||
}) as typeof actual.writeFile,
|
||||
stat: (async (path: unknown, ...rest: never[]) => {
|
||||
inject('stat', path)
|
||||
return (actual.stat as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
|
||||
}) as typeof actual.stat,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -62,36 +58,24 @@ async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Pro
|
||||
}
|
||||
|
||||
describe('writer-lock races', () => {
|
||||
it('retries immediately when the contending lock vanished before the stat', async () => {
|
||||
it('retries when the contending lock vanished after the failed create', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
// The exclusive create loses to a holder that releases before the stat:
|
||||
// no lock file actually exists, so the stat sees honest absence and the
|
||||
// very next attempt takes the lock.
|
||||
state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' })
|
||||
// The exclusive create loses once, but no lock remains by the retry.
|
||||
state.failures.push({ suffix: '.lock', code: 'EEXIST' })
|
||||
await scope.update({ value: 3 })
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 3')
|
||||
})
|
||||
|
||||
it('propagates a stat failure that does not mean absence', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' })
|
||||
state.failures.push({ op: 'stat', suffix: '.lock', code: 'EACCES' })
|
||||
await expect(scope.update({ value: 3 })).rejects.toThrow(/EACCES/)
|
||||
})
|
||||
|
||||
it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'alpha:\n value: 1\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
state.failures.push({ op: 'writeFile', suffix: '.tmp', code: 'ENOSPC' })
|
||||
state.failures.push({ suffix: '.tmp', code: 'ENOSPC' })
|
||||
await expect(scope.update({ value: 9 })).rejects.toThrow(/ENOSPC/)
|
||||
// The document is untouched and the writer lock was released on the way out.
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 1')
|
||||
|
||||
Reference in New Issue
Block a user