| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149 |
- 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<typeof import('node:fs/promises')>()
- 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<void>)(path, ...rest)
- }) as typeof actual.writeFile,
- }
- })
- afterEach(() => {
- state.failLockCreateWithEPERM = false
- })
- async function scratch(): Promise<string> {
- 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<void> {
- 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<void>((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')
- })
- })
|