atomic-write.spec.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { 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(() => ({ failLockCreateWithEPERM: false }))
  7. vi.mock('node:fs/promises', async (importOriginal) => {
  8. const actual = await importOriginal<typeof import('node:fs/promises')>()
  9. return {
  10. ...actual,
  11. writeFile: (async (path: unknown, ...rest: never[]) => {
  12. if (state.failLockCreateWithEPERM && String(path).endsWith('.lock')) {
  13. state.failLockCreateWithEPERM = false
  14. throw Object.assign(new Error('EPERM: injected exclusive-create failure'), { code: 'EPERM' })
  15. }
  16. return (actual.writeFile as (path: unknown, ...args: never[]) => Promise<void>)(path, ...rest)
  17. }) as typeof actual.writeFile,
  18. }
  19. })
  20. afterEach(() => {
  21. state.failLockCreateWithEPERM = false
  22. })
  23. async function scratch(): Promise<string> {
  24. return mkdtemp(join(tmpdir(), 'dsh-atomic-write-'))
  25. }
  26. /** Resolve once the lockfile exists, so contention is measured against a held lock. */
  27. async function waitForLock(lockPath: string): Promise<void> {
  28. for (;;) {
  29. try {
  30. await stat(lockPath)
  31. return
  32. } catch {
  33. await new Promise(resolve => setTimeout(resolve, 5))
  34. }
  35. }
  36. }
  37. describe('writeFileAtomic', () => {
  38. it('creates the file and its parents with exactly the stated mode', async () => {
  39. const dir = await scratch()
  40. const target = join(dir, 'nested', 'deep', 'doc.yaml')
  41. await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 })
  42. expect(await readFile(target, 'utf8')).toBe('a: 1\n')
  43. if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600)
  44. })
  45. it('replaces existing content and narrows a wider-permission file to the stated mode', async () => {
  46. const dir = await scratch()
  47. const target = join(dir, 'doc.yaml')
  48. await writeFile(target, 'old', { mode: 0o644 })
  49. await writeFileAtomic(target, 'new', { mode: 0o600 })
  50. expect(await readFile(target, 'utf8')).toBe('new')
  51. if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600)
  52. })
  53. it('replaces a symlinked target itself without writing through to the referent', async () => {
  54. const dir = await scratch()
  55. const victim = join(dir, 'victim')
  56. await writeFile(victim, 'victim-content')
  57. const target = join(dir, 'doc.yaml')
  58. await symlink(victim, target)
  59. await writeFileAtomic(target, 'replaced', { mode: 0o600 })
  60. expect((await lstat(target)).isSymbolicLink()).toBe(false)
  61. expect(await readFile(target, 'utf8')).toBe('replaced')
  62. expect(await readFile(victim, 'utf8')).toBe('victim-content')
  63. })
  64. it('leaves no temp sibling and rethrows when the rename fails', async () => {
  65. const dir = await scratch()
  66. const target = join(dir, 'occupied')
  67. await mkdir(target)
  68. await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow()
  69. expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
  70. })
  71. })
  72. describe('withFileLock', () => {
  73. it('retries EPERM only when the lock path currently exists', async () => {
  74. const dir = await scratch()
  75. const target = join(dir, 'document')
  76. const lockPath = `${target}.lock`
  77. await writeFile(lockPath, 'holder\n')
  78. const release = setTimeout(() => { void rm(lockPath, { force: true }) }, 50)
  79. state.failLockCreateWithEPERM = true
  80. let called = false
  81. try {
  82. await withFileLock(target, async () => { called = true })
  83. } finally {
  84. clearTimeout(release)
  85. }
  86. expect(called).toBe(true)
  87. })
  88. it('preserves EPERM when no lock path exists', async () => {
  89. const dir = await scratch()
  90. const operation = vi.fn(async () => {})
  91. state.failLockCreateWithEPERM = true
  92. await expect(withFileLock(join(dir, 'document'), operation)).rejects.toMatchObject({ code: 'EPERM' })
  93. expect(operation).not.toHaveBeenCalled()
  94. })
  95. it('rejects an invalid parent hierarchy before running the operation', async () => {
  96. const dir = await scratch()
  97. const parent = join(dir, 'not-a-directory')
  98. await writeFile(parent, 'occupied')
  99. let called = false
  100. await expect(withFileLock(join(parent, 'document'), async () => {
  101. called = true
  102. })).rejects.toThrow(/ENOENT|ENOTDIR|not a directory/i)
  103. expect(called).toBe(false)
  104. })
  105. it('waits for the caller-stated limit rather than the protocol default', async () => {
  106. // An operation whose work includes a network round trip legitimately holds
  107. // the lock far longer than the render-and-rename the default was sized
  108. // for. The limit is per call so one such operation cannot fail every other
  109. // writer of the same file, and a caller that states a short one still
  110. // fails fast.
  111. const dir = await scratch()
  112. const target = join(dir, 'document')
  113. let release = (): void => {}
  114. const held = new Promise<void>((resolve) => { release = resolve })
  115. const holder = withFileLock(target, () => held)
  116. // The holder owns the lock once its lockfile exists; contending before
  117. // that would measure nothing.
  118. await waitForLock(`${target}.lock`)
  119. // Elapsed time is the assertion that distinguishes a honoured limit from
  120. // the ignored argument: without it the contender simply waits out the
  121. // protocol default and fails with the same message.
  122. const startedAt = Date.now()
  123. await expect(withFileLock(target, async () => 'impatient', { waitMs: 50 }))
  124. .rejects.toThrow(/timed out waiting for the writer lock/)
  125. expect(Date.now() - startedAt).toBeLessThan(1_000)
  126. const patient = withFileLock(target, async () => 'patient', { waitMs: 10_000 })
  127. release()
  128. await holder
  129. expect(await patient).toBe('patient')
  130. })
  131. })