atomic-write.spec.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import { lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { dirname, join } from 'node:path'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import { withFileLock, writeFileAtomic } from '../src/index.ts'
  6. const state = vi.hoisted(() => ({
  7. lockPermissionFailures: 0,
  8. releaseLockBeforeProbe: false,
  9. renameAttempts: 0,
  10. renameFailures: [] as string[],
  11. }))
  12. vi.mock('node:fs/promises', async (importOriginal) => {
  13. const actual = await importOriginal<typeof import('node:fs/promises')>()
  14. return {
  15. ...actual,
  16. rename: (async (...args: Parameters<typeof actual.rename>) => {
  17. state.renameAttempts += 1
  18. const code = state.renameFailures.shift()
  19. if (code !== undefined) {
  20. if (code === 'NO_CODE') throw new Error('injected rename failure without a code')
  21. throw Object.assign(new Error(`${code}: injected rename failure`), { code })
  22. }
  23. return actual.rename(...args)
  24. }),
  25. writeFile: (async (path: unknown, ...rest: never[]) => {
  26. if (state.lockPermissionFailures > 0 && String(path).endsWith('.lock')) {
  27. state.lockPermissionFailures -= 1
  28. if (state.releaseLockBeforeProbe) await actual.rm(String(path))
  29. throw Object.assign(new Error('EPERM: injected exclusive-create failure'), { code: 'EPERM' })
  30. }
  31. return (actual.writeFile as (path: unknown, ...args: never[]) => Promise<void>)(path, ...rest)
  32. }) as typeof actual.writeFile,
  33. }
  34. })
  35. const scratchDirs: string[] = []
  36. afterEach(async () => {
  37. vi.useRealTimers()
  38. vi.restoreAllMocks()
  39. state.lockPermissionFailures = 0
  40. state.releaseLockBeforeProbe = false
  41. state.renameAttempts = 0
  42. state.renameFailures.length = 0
  43. await Promise.all(scratchDirs.splice(0).map(dir => rm(dir, {
  44. force: true,
  45. maxRetries: 10,
  46. recursive: true,
  47. retryDelay: 20,
  48. })))
  49. })
  50. async function scratch(): Promise<string> {
  51. const dir = await mkdtemp(join(tmpdir(), 'dsh-atomic-write-'))
  52. scratchDirs.push(dir)
  53. return dir
  54. }
  55. /** Resolve once the lockfile exists, so contention is measured against a held lock. */
  56. async function waitForLock(lockPath: string): Promise<void> {
  57. for (;;) {
  58. try {
  59. await stat(lockPath)
  60. return
  61. } catch {
  62. await new Promise(resolve => setTimeout(resolve, 5))
  63. }
  64. }
  65. }
  66. describe('writeFileAtomic', () => {
  67. it('creates the file and its parents with exactly the stated mode', async () => {
  68. const dir = await scratch()
  69. const target = join(dir, 'nested', 'deep', 'doc.yaml')
  70. await writeFileAtomic(target, 'a: 1\n', { dirMode: 0o700, mode: 0o600 })
  71. expect(await readFile(target, 'utf8')).toBe('a: 1\n')
  72. if (process.platform !== 'win32') {
  73. expect((await stat(dirname(target))).mode & 0o777).toBe(0o700)
  74. expect((await stat(target)).mode & 0o777).toBe(0o600)
  75. }
  76. })
  77. it('replaces existing content and narrows a wider-permission file to the stated mode', async () => {
  78. const dir = await scratch()
  79. const target = join(dir, 'doc.yaml')
  80. await writeFile(target, 'old', { mode: 0o644 })
  81. await writeFileAtomic(target, 'new', { mode: 0o600 })
  82. expect(await readFile(target, 'utf8')).toBe('new')
  83. if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600)
  84. })
  85. it('replaces a symlinked target itself without writing through to the referent', async () => {
  86. const dir = await scratch()
  87. const victim = join(dir, 'victim')
  88. await writeFile(victim, 'victim-content')
  89. const target = join(dir, 'doc.yaml')
  90. await symlink(victim, target)
  91. await writeFileAtomic(target, 'replaced', { mode: 0o600 })
  92. expect((await lstat(target)).isSymbolicLink()).toBe(false)
  93. expect(await readFile(target, 'utf8')).toBe('replaced')
  94. expect(await readFile(victim, 'utf8')).toBe('victim-content')
  95. })
  96. it('retries transient Windows rename interference and commits the replacement', async () => {
  97. vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
  98. vi.useFakeTimers()
  99. const dir = await scratch()
  100. const target = join(dir, 'document')
  101. await writeFile(target, 'old')
  102. state.renameFailures.push('EACCES', 'EBUSY', 'EPERM')
  103. const replacement = writeFileAtomic(target, 'new', { mode: 0o600 })
  104. await vi.waitFor(() => { expect(state.renameAttempts).toBeGreaterThan(0) })
  105. await vi.runAllTimersAsync()
  106. await replacement
  107. expect(state.renameAttempts).toBe(4)
  108. expect(await readFile(target, 'utf8')).toBe('new')
  109. expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
  110. })
  111. it('leaves no temp sibling after bounded Windows rename retries expire', async () => {
  112. vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
  113. vi.useFakeTimers()
  114. const dir = await scratch()
  115. const target = join(dir, 'document')
  116. await writeFile(target, 'old')
  117. state.renameFailures.push(...Array.from({ length: 9 }, () => 'EPERM'))
  118. const replacement = writeFileAtomic(target, 'new', { mode: 0o600 })
  119. await vi.waitFor(() => { expect(state.renameAttempts).toBeGreaterThan(0) })
  120. await vi.runAllTimersAsync()
  121. await expect(replacement).rejects.toMatchObject({ code: 'EPERM' })
  122. expect(state.renameAttempts).toBe(9)
  123. expect(await readFile(target, 'utf8')).toBe('old')
  124. expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
  125. })
  126. it('does not retry a Windows rename failure without a transient code', async () => {
  127. vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
  128. const dir = await scratch()
  129. const target = join(dir, 'document')
  130. state.renameFailures.push('NO_CODE')
  131. await expect(writeFileAtomic(target, 'new', { mode: 0o600 })).rejects.toThrow(/without a code/)
  132. expect(state.renameAttempts).toBe(1)
  133. expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
  134. })
  135. it('does not retry rename permission failures outside Windows', async () => {
  136. vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
  137. const dir = await scratch()
  138. const target = join(dir, 'document')
  139. state.renameFailures.push('EPERM')
  140. await expect(writeFileAtomic(target, 'new', { mode: 0o600 })).rejects.toMatchObject({ code: 'EPERM' })
  141. expect(state.renameAttempts).toBe(1)
  142. })
  143. })
  144. describe('withFileLock', () => {
  145. it('retries EPERM only when the lock path currently exists', async () => {
  146. const dir = await scratch()
  147. const target = join(dir, 'document')
  148. const lockPath = `${target}.lock`
  149. await writeFile(lockPath, 'holder\n')
  150. const release = setTimeout(() => { void rm(lockPath, { force: true }) }, 50)
  151. state.lockPermissionFailures = 1
  152. let called = false
  153. try {
  154. await withFileLock(target, async () => { called = true })
  155. } finally {
  156. clearTimeout(release)
  157. }
  158. expect(called).toBe(true)
  159. })
  160. it.each(['win32', 'linux'] as const)('preserves persistent EPERM on %s when no lock path exists', async (platform) => {
  161. vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
  162. const dir = await scratch()
  163. const operation = vi.fn(async () => {})
  164. state.lockPermissionFailures = 2
  165. await expect(withFileLock(join(dir, 'document'), operation)).rejects.toMatchObject({ code: 'EPERM' })
  166. expect(operation).not.toHaveBeenCalled()
  167. })
  168. it('acquires the Windows lock when its holder releases before the contention probe', async () => {
  169. vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
  170. const dir = await scratch()
  171. const target = join(dir, 'document')
  172. await writeFile(`${target}.lock`, 'holder\n')
  173. state.lockPermissionFailures = 1
  174. state.releaseLockBeforeProbe = true
  175. const operation = vi.fn(async () => 'acquired')
  176. await expect(withFileLock(target, operation)).resolves.toBe('acquired')
  177. expect(operation).toHaveBeenCalledOnce()
  178. expect(await readdir(dir)).toEqual([])
  179. })
  180. it('rejects an invalid parent hierarchy before running the operation', async () => {
  181. const dir = await scratch()
  182. const parent = join(dir, 'not-a-directory')
  183. await writeFile(parent, 'occupied')
  184. let called = false
  185. await expect(withFileLock(join(parent, 'document'), async () => {
  186. called = true
  187. })).rejects.toThrow(/ENOENT|ENOTDIR|not a directory/i)
  188. expect(called).toBe(false)
  189. })
  190. it('waits for the caller-stated limit rather than the protocol default', async () => {
  191. // An operation whose work includes a network round trip legitimately holds
  192. // the lock far longer than the render-and-rename the default was sized
  193. // for. The limit is per call so one such operation cannot fail every other
  194. // writer of the same file, and a caller that states a short one still
  195. // fails fast.
  196. const dir = await scratch()
  197. const target = join(dir, 'document')
  198. let release = (): void => {}
  199. const held = new Promise<void>((resolve) => { release = resolve })
  200. const holder = withFileLock(target, () => held)
  201. // The holder owns the lock once its lockfile exists; contending before
  202. // that would measure nothing.
  203. await waitForLock(`${target}.lock`)
  204. // Elapsed time is the assertion that distinguishes a honoured limit from
  205. // the ignored argument: without it the contender simply waits out the
  206. // protocol default and fails with the same message.
  207. const startedAt = Date.now()
  208. await expect(withFileLock(target, async () => 'impatient', { waitMs: 50 }))
  209. .rejects.toThrow(/timed out waiting for the writer lock/)
  210. expect(Date.now() - startedAt).toBeLessThan(1_000)
  211. const patient = withFileLock(target, async () => 'patient', { waitMs: 10_000 })
  212. release()
  213. await holder
  214. expect(await patient).toBe('patient')
  215. })
  216. })