import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { withFileLock, writeFileAtomic } from '../src/index.ts' const state = vi.hoisted(() => ({ failLockCreateWithEPERM: false })) vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() return { ...actual, writeFile: (async (path: unknown, ...rest: never[]) => { if (state.failLockCreateWithEPERM && String(path).endsWith('.lock')) { state.failLockCreateWithEPERM = false throw Object.assign(new Error('EPERM: injected exclusive-create failure'), { code: 'EPERM' }) } return (actual.writeFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) }) as typeof actual.writeFile, } }) afterEach(() => { state.failLockCreateWithEPERM = false }) async function scratch(): Promise { return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) } /** Resolve once the lockfile exists, so contention is measured against a held lock. */ async function waitForLock(lockPath: string): Promise { for (;;) { try { await stat(lockPath) return } catch { await new Promise(resolve => setTimeout(resolve, 5)) } } } describe('writeFileAtomic', () => { it('creates the file and its parents with exactly the stated mode', async () => { const dir = await scratch() const target = join(dir, 'nested', 'deep', 'doc.yaml') await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 }) expect(await readFile(target, 'utf8')).toBe('a: 1\n') if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600) }) it('replaces existing content and narrows a wider-permission file to the stated mode', async () => { const dir = await scratch() const target = join(dir, 'doc.yaml') await writeFile(target, 'old', { mode: 0o644 }) await writeFileAtomic(target, 'new', { mode: 0o600 }) expect(await readFile(target, 'utf8')).toBe('new') if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600) }) it('replaces a symlinked target itself without writing through to the referent', async () => { const dir = await scratch() const victim = join(dir, 'victim') await writeFile(victim, 'victim-content') const target = join(dir, 'doc.yaml') await symlink(victim, target) await writeFileAtomic(target, 'replaced', { mode: 0o600 }) expect((await lstat(target)).isSymbolicLink()).toBe(false) expect(await readFile(target, 'utf8')).toBe('replaced') expect(await readFile(victim, 'utf8')).toBe('victim-content') }) it('leaves no temp sibling and rethrows when the rename fails', async () => { const dir = await scratch() const target = join(dir, 'occupied') await mkdir(target) await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow() expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([]) }) }) describe('withFileLock', () => { it('retries EPERM only when the lock path currently exists', async () => { const dir = await scratch() const target = join(dir, 'document') const lockPath = `${target}.lock` await writeFile(lockPath, 'holder\n') const release = setTimeout(() => { void rm(lockPath, { force: true }) }, 50) state.failLockCreateWithEPERM = true let called = false try { await withFileLock(target, async () => { called = true }) } finally { clearTimeout(release) } expect(called).toBe(true) }) it('preserves EPERM when no lock path exists', async () => { const dir = await scratch() const operation = vi.fn(async () => {}) state.failLockCreateWithEPERM = true await expect(withFileLock(join(dir, 'document'), operation)).rejects.toMatchObject({ code: 'EPERM' }) expect(operation).not.toHaveBeenCalled() }) it('rejects an invalid parent hierarchy before running the operation', async () => { const dir = await scratch() const parent = join(dir, 'not-a-directory') await writeFile(parent, 'occupied') let called = false await expect(withFileLock(join(parent, 'document'), async () => { called = true })).rejects.toThrow(/ENOENT|ENOTDIR|not a directory/i) expect(called).toBe(false) }) it('waits for the caller-stated limit rather than the protocol default', async () => { // An operation whose work includes a network round trip legitimately holds // the lock far longer than the render-and-rename the default was sized // for. The limit is per call so one such operation cannot fail every other // writer of the same file, and a caller that states a short one still // fails fast. const dir = await scratch() const target = join(dir, 'document') let release = (): void => {} const held = new Promise((resolve) => { release = resolve }) const holder = withFileLock(target, () => held) // The holder owns the lock once its lockfile exists; contending before // that would measure nothing. await waitForLock(`${target}.lock`) // Elapsed time is the assertion that distinguishes a honoured limit from // the ignored argument: without it the contender simply waits out the // protocol default and fails with the same message. const startedAt = Date.now() await expect(withFileLock(target, async () => 'impatient', { waitMs: 50 })) .rejects.toThrow(/timed out waiting for the writer lock/) expect(Date.now() - startedAt).toBeLessThan(1_000) const patient = withFileLock(target, async () => 'patient', { waitMs: 10_000 }) release() await holder expect(await patient).toBe('patient') }) })