index.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. /**
  2. * Zero-dependency atomic file replacement and writer coordination.
  3. * `writeFileAtomic` writes a random-suffix sibling with exclusive create and
  4. * the caller's permission bits, then renames it over the target, so readers
  5. * observe either the old or the new complete content and a replaced file ends
  6. * up with exactly the stated mode. `withFileLock` serializes cross-process
  7. * writers of one file through a `wx`-created `<file>.lock` sibling, so a
  8. * read-modify-write cycle can never resurrect a state another writer just
  9. * replaced; readers stay lock-free because the rename commit is atomic.
  10. * @module @deepseek-ai/dsh-atomic-write
  11. */
  12. import { randomBytes } from 'node:crypto'
  13. import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
  14. import { dirname } from 'node:path'
  15. /**
  16. * Filesystem options for {@link writeFileAtomic}; `mode` is required so the
  17. * permission decision stays visible at every call site.
  18. */
  19. export interface WriteFileAtomicOptions {
  20. /**
  21. * Permission bits stamped on the fresh temp inode and carried through the
  22. * rename (subject to the process umask, like every fresh inode).
  23. */
  24. mode: number
  25. /**
  26. * Permission bits for parent directories this call creates (subject to the
  27. * umask; existing directories keep their mode). Omission uses the mkdir
  28. * default — pass `0o700` when the tree holds user-private data.
  29. */
  30. dirMode?: number
  31. }
  32. /**
  33. * Replace `filename` with `content` in one atomic step, creating parent
  34. * directories. The content is first written to a random-suffix sibling opened
  35. * with exclusive create (`wx`): the open refuses to follow a symlink planted
  36. * at the temp path, and the fresh inode carries `options.mode` through the
  37. * rename, so replacing a wider-permission file narrows it without a chmod
  38. * race. The rename also replaces a symlinked target itself instead of writing
  39. * through to its referent, and the same-directory sibling keeps the rename on
  40. * one filesystem. On any failure the temp file is removed and the failure
  41. * rethrown. Crash durability (fsync) is out of scope.
  42. * @param filename - final path receiving the content.
  43. * @param content - complete next file content.
  44. * @param options - permission bits for the replacement inode.
  45. */
  46. export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise<void> {
  47. await mkdir(dirname(filename), {
  48. recursive: true,
  49. ...options.dirMode === undefined ? {} : { mode: options.dirMode },
  50. })
  51. // TODO(settings-atomic-durability): Use a replacement that fsyncs the file
  52. // and parent directory and preserves owner-only permissions on Windows.
  53. const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp`
  54. try {
  55. await writeFile(temp, content, { mode: options.mode, flag: 'wx' })
  56. await rename(temp, filename)
  57. } catch (error) {
  58. await rm(temp, { force: true })
  59. throw error
  60. }
  61. }
  62. /** Whether an exclusive create failed because the path already exists. */
  63. function isEEXIST(error: unknown): boolean {
  64. return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
  65. }
  66. /**
  67. * Writer-lock protocol constants. These are robustness invariants of the
  68. * cross-process write protocol, not deployment tunables: contention normally
  69. * resolves within the retry deadline, while expiry fails the contender without
  70. * guessing whether the existing lock still has an owner.
  71. */
  72. const LOCK_RETRY_INITIAL_MS = 20
  73. const LOCK_RETRY_MAX_MS = 200
  74. const LOCK_TIMEOUT_MS = 2_000
  75. /**
  76. * Hold the cross-process writer lock for `filename` around one operation. The
  77. * lock is a `wx`-created sibling (`<filename>.lock`); paired with the
  78. * rename-based commit of {@link writeFileAtomic}, readers stay lock-free and
  79. * only writers contend. Contention backs off exponentially and fails with a
  80. * timed-out error after the deadline. The contender never removes an existing
  81. * lock because file age cannot prove that its owner stopped; orphan recovery
  82. * is an operator action. The parent directory must exist.
  83. * @param filename - the file whose writers this lock serializes.
  84. * @param operation - the read-render-commit cycle to run while holding the lock.
  85. * @returns the operation's result; the lock releases on both outcomes.
  86. */
  87. export async function withFileLock<T>(
  88. filename: string,
  89. operation: () => Promise<T>,
  90. ): Promise<T> {
  91. const lockPath = `${filename}.lock`
  92. const deadline = Date.now() + LOCK_TIMEOUT_MS
  93. let delay = LOCK_RETRY_INITIAL_MS
  94. for (;;) {
  95. try {
  96. await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
  97. break
  98. } catch (error) {
  99. if (!isEEXIST(error)) throw error
  100. }
  101. if (Date.now() >= deadline) {
  102. throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`)
  103. }
  104. await new Promise(resolve => setTimeout(resolve, delay))
  105. delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS)
  106. }
  107. try {
  108. return await operation()
  109. } finally {
  110. await rm(lockPath, { force: true })
  111. }
  112. }