|
|
@@ -1,15 +1,28 @@
|
|
|
-import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
|
|
+import { lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
|
|
import { tmpdir } from 'node:os'
|
|
|
-import { join } from 'node:path'
|
|
|
+import { dirname, 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 }))
|
|
|
+const state = vi.hoisted(() => ({
|
|
|
+ failLockCreateWithEPERM: false,
|
|
|
+ renameAttempts: 0,
|
|
|
+ renameFailures: [] as string[],
|
|
|
+}))
|
|
|
|
|
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
|
|
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
|
|
return {
|
|
|
...actual,
|
|
|
+ rename: (async (...args: Parameters<typeof actual.rename>) => {
|
|
|
+ state.renameAttempts += 1
|
|
|
+ const code = state.renameFailures.shift()
|
|
|
+ if (code !== undefined) {
|
|
|
+ if (code === 'NO_CODE') throw new Error('injected rename failure without a code')
|
|
|
+ throw Object.assign(new Error(`${code}: injected rename failure`), { code })
|
|
|
+ }
|
|
|
+ return actual.rename(...args)
|
|
|
+ }),
|
|
|
writeFile: (async (path: unknown, ...rest: never[]) => {
|
|
|
if (state.failLockCreateWithEPERM && String(path).endsWith('.lock')) {
|
|
|
state.failLockCreateWithEPERM = false
|
|
|
@@ -20,12 +33,26 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
-afterEach(() => {
|
|
|
+const scratchDirs: string[] = []
|
|
|
+
|
|
|
+afterEach(async () => {
|
|
|
+ vi.useRealTimers()
|
|
|
+ vi.restoreAllMocks()
|
|
|
state.failLockCreateWithEPERM = false
|
|
|
+ state.renameAttempts = 0
|
|
|
+ state.renameFailures.length = 0
|
|
|
+ await Promise.all(scratchDirs.splice(0).map(dir => rm(dir, {
|
|
|
+ force: true,
|
|
|
+ maxRetries: 10,
|
|
|
+ recursive: true,
|
|
|
+ retryDelay: 20,
|
|
|
+ })))
|
|
|
})
|
|
|
|
|
|
async function scratch(): Promise<string> {
|
|
|
- return mkdtemp(join(tmpdir(), 'dsh-atomic-write-'))
|
|
|
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-atomic-write-'))
|
|
|
+ scratchDirs.push(dir)
|
|
|
+ return dir
|
|
|
}
|
|
|
|
|
|
/** Resolve once the lockfile exists, so contention is measured against a held lock. */
|
|
|
@@ -44,9 +71,12 @@ 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 })
|
|
|
+ await writeFileAtomic(target, 'a: 1\n', { dirMode: 0o700, mode: 0o600 })
|
|
|
expect(await readFile(target, 'utf8')).toBe('a: 1\n')
|
|
|
- if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600)
|
|
|
+ if (process.platform !== 'win32') {
|
|
|
+ expect((await stat(dirname(target))).mode & 0o777).toBe(0o700)
|
|
|
+ expect((await stat(target)).mode & 0o777).toBe(0o600)
|
|
|
+ }
|
|
|
})
|
|
|
|
|
|
it('replaces existing content and narrows a wider-permission file to the stated mode', async () => {
|
|
|
@@ -70,13 +100,62 @@ describe('writeFileAtomic', () => {
|
|
|
expect(await readFile(victim, 'utf8')).toBe('victim-content')
|
|
|
})
|
|
|
|
|
|
- it('leaves no temp sibling and rethrows when the rename fails', async () => {
|
|
|
+ it('retries transient Windows rename interference and commits the replacement', async () => {
|
|
|
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
|
|
+ vi.useFakeTimers()
|
|
|
+ const dir = await scratch()
|
|
|
+ const target = join(dir, 'document')
|
|
|
+ await writeFile(target, 'old')
|
|
|
+ state.renameFailures.push('EACCES', 'EBUSY', 'EPERM')
|
|
|
+
|
|
|
+ const replacement = writeFileAtomic(target, 'new', { mode: 0o600 })
|
|
|
+ await vi.waitFor(() => { expect(state.renameAttempts).toBeGreaterThan(0) })
|
|
|
+ await vi.runAllTimersAsync()
|
|
|
+ await replacement
|
|
|
+
|
|
|
+ expect(state.renameAttempts).toBe(4)
|
|
|
+ expect(await readFile(target, 'utf8')).toBe('new')
|
|
|
+ expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('leaves no temp sibling after bounded Windows rename retries expire', async () => {
|
|
|
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
|
|
+ vi.useFakeTimers()
|
|
|
+ const dir = await scratch()
|
|
|
+ const target = join(dir, 'document')
|
|
|
+ await writeFile(target, 'old')
|
|
|
+ state.renameFailures.push(...Array.from({ length: 9 }, () => 'EPERM'))
|
|
|
+
|
|
|
+ const replacement = writeFileAtomic(target, 'new', { mode: 0o600 })
|
|
|
+ await vi.waitFor(() => { expect(state.renameAttempts).toBeGreaterThan(0) })
|
|
|
+ await vi.runAllTimersAsync()
|
|
|
+ await expect(replacement).rejects.toMatchObject({ code: 'EPERM' })
|
|
|
+
|
|
|
+ expect(state.renameAttempts).toBe(9)
|
|
|
+ expect(await readFile(target, 'utf8')).toBe('old')
|
|
|
+ expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('does not retry a Windows rename failure without a transient code', async () => {
|
|
|
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
|
|
const dir = await scratch()
|
|
|
- const target = join(dir, 'occupied')
|
|
|
- await mkdir(target)
|
|
|
- await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow()
|
|
|
+ const target = join(dir, 'document')
|
|
|
+ state.renameFailures.push('NO_CODE')
|
|
|
+
|
|
|
+ await expect(writeFileAtomic(target, 'new', { mode: 0o600 })).rejects.toThrow(/without a code/)
|
|
|
+ expect(state.renameAttempts).toBe(1)
|
|
|
expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
|
|
|
})
|
|
|
+
|
|
|
+ it('does not retry rename permission failures outside Windows', async () => {
|
|
|
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
|
|
+ const dir = await scratch()
|
|
|
+ const target = join(dir, 'document')
|
|
|
+ state.renameFailures.push('EPERM')
|
|
|
+
|
|
|
+ await expect(writeFileAtomic(target, 'new', { mode: 0o600 })).rejects.toMatchObject({ code: 'EPERM' })
|
|
|
+ expect(state.renameAttempts).toBe(1)
|
|
|
+ })
|
|
|
})
|
|
|
|
|
|
describe('withFileLock', () => {
|