atomic-write.spec.ts 8.7 KB

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