index.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  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 { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises'
  14. import { dirname } from 'node:path'
  15. const WINDOWS_TRANSIENT_RENAME_ERRORS: ReadonlySet<string> = new Set(['EACCES', 'EBUSY', 'EPERM'])
  16. const WINDOWS_RENAME_RETRY_INITIAL_MS = 20
  17. const WINDOWS_RENAME_RETRY_MAX_MS = 200
  18. const WINDOWS_RENAME_RETRY_LIMIT = 8
  19. /** Whether Windows reported temporary interference with an atomic replacement. */
  20. function isTransientWindowsRenameError(error: unknown): boolean {
  21. if (process.platform !== 'win32') return false
  22. return WINDOWS_TRANSIENT_RENAME_ERRORS.has((error as NodeJS.ErrnoException | null)?.code ?? '')
  23. }
  24. /** Replace the target after bounded retries for transient Windows interference. */
  25. async function renameAtomicTemp(temp: string, filename: string): Promise<void> {
  26. let delay = WINDOWS_RENAME_RETRY_INITIAL_MS
  27. for (let retries = 0;; retries += 1) {
  28. try {
  29. await rename(temp, filename)
  30. return
  31. } catch (error) {
  32. if (!isTransientWindowsRenameError(error)) throw error
  33. if (retries >= WINDOWS_RENAME_RETRY_LIMIT) throw error
  34. }
  35. await new Promise(resolve => setTimeout(resolve, delay))
  36. delay = Math.min(delay * 2, WINDOWS_RENAME_RETRY_MAX_MS)
  37. }
  38. }
  39. /**
  40. * Filesystem options for {@link writeFileAtomic}; `mode` is required so the
  41. * permission decision stays visible at every call site.
  42. */
  43. export interface WriteFileAtomicOptions {
  44. /**
  45. * Permission bits stamped on the fresh temp inode and carried through the
  46. * rename (subject to the process umask, like every fresh inode).
  47. */
  48. mode: number
  49. /**
  50. * Permission bits for parent directories this call creates (subject to the
  51. * umask; existing directories keep their mode). Omission uses the mkdir
  52. * default — pass `0o700` when the tree holds user-private data.
  53. */
  54. dirMode?: number
  55. }
  56. /**
  57. * Replace `filename` with `content` in one atomic step, creating parent
  58. * directories. The content is first written to a random-suffix sibling opened
  59. * with exclusive create (`wx`): the open refuses to follow a symlink planted
  60. * at the temp path, and the fresh inode carries `options.mode` through the
  61. * rename, so replacing a wider-permission file narrows it without a chmod
  62. * race. The rename also replaces a symlinked target itself instead of writing
  63. * through to its referent, and the same-directory sibling keeps the rename on
  64. * one filesystem. Windows replacement retries transient `EACCES`, `EBUSY`,
  65. * and `EPERM` failures for a bounded interval while the complete temp file
  66. * remains the rename source. On any remaining failure the temp file is
  67. * removed and the failure rethrown. Crash durability (fsync) is out of scope.
  68. * @param filename - final path receiving the content.
  69. * @param content - complete next file content.
  70. * @param options - permission bits for the replacement inode.
  71. */
  72. export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise<void> {
  73. await mkdir(dirname(filename), {
  74. recursive: true,
  75. ...options.dirMode === undefined ? {} : { mode: options.dirMode },
  76. })
  77. // TODO(settings-atomic-durability): Use a replacement that fsyncs the file
  78. // and parent directory and preserves owner-only permissions on Windows.
  79. const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp`
  80. try {
  81. await writeFile(temp, content, { mode: options.mode, flag: 'wx' })
  82. await renameAtomicTemp(temp, filename)
  83. } catch (error) {
  84. await rm(temp, { force: true })
  85. throw error
  86. }
  87. }
  88. /** Whether an exclusive create found an existing lock. */
  89. async function isLockContention(error: unknown, lockPath: string): Promise<boolean> {
  90. const code = (error as NodeJS.ErrnoException | null)?.code
  91. if (code === 'EEXIST') return true
  92. if (code !== 'EPERM') return false
  93. try {
  94. await lstat(lockPath)
  95. return true
  96. } catch {
  97. // Keep the original EPERM authoritative when lock existence is unproven.
  98. return false
  99. }
  100. }
  101. /**
  102. * Retry cadence for a contended lock. These stay robustness invariants of the
  103. * cross-process write protocol rather than deployment tunables: they govern how
  104. * often a contender asks, which no caller has a reason to vary.
  105. */
  106. const LOCK_RETRY_INITIAL_MS = 20
  107. const LOCK_RETRY_MAX_MS = 200
  108. /**
  109. * How long a contender waits when the caller states no limit — sized for the
  110. * render-and-rename cycle every call site had when this package was written.
  111. * Expiry fails the contender rather than guessing whether the existing lock
  112. * still has an owner. How long is *worth* waiting is a property of the
  113. * operation the lock holder runs, which is why {@link FileLockOptions.waitMs}
  114. * exists; the value here is the floor for an operation that does file work
  115. * alone.
  116. */
  117. const DEFAULT_LOCK_WAIT_MS = 2_000
  118. /** Options for one {@link withFileLock} acquisition. */
  119. export interface FileLockOptions {
  120. /**
  121. * Maximum time to wait for the lock, in milliseconds. State one when the
  122. * holder's operation legitimately runs longer than file work — a credential
  123. * mutation that refreshes a token performs a network round trip while
  124. * holding the lock, and leaving the default in place would fail every other
  125. * writer of the same file for the duration. Waiting is productive: a
  126. * contender that acquires the lock afterwards re-reads the committed state.
  127. */
  128. waitMs?: number
  129. }
  130. /**
  131. * Hold the cross-process writer lock for `filename` around one operation. The
  132. * lock is a `wx`-created sibling (`<filename>.lock`); paired with the
  133. * rename-based commit of {@link writeFileAtomic}, readers stay lock-free and
  134. * only writers contend. `EEXIST` is contention directly; an `EPERM` is
  135. * contention only when a fresh `lstat` confirms the lock path exists, covering
  136. * Windows exclusive-create behavior without hiding an unrelated permission
  137. * failure. Contention backs off exponentially and fails with a timed-out error
  138. * after the deadline. The contender never removes an existing lock because
  139. * file age cannot prove that its owner stopped; orphan recovery is an operator
  140. * action. The parent directory must exist.
  141. * @param filename - the file whose writers this lock serializes.
  142. * @param operation - the read-render-commit cycle to run while holding the lock.
  143. * @param options - acquisition options; omitted waits {@link DEFAULT_LOCK_WAIT_MS}.
  144. * @returns the operation's result; the lock releases on both outcomes.
  145. */
  146. export async function withFileLock<T>(
  147. filename: string,
  148. operation: () => Promise<T>,
  149. options?: FileLockOptions,
  150. ): Promise<T> {
  151. const lockPath = `${filename}.lock`
  152. const deadline = Date.now() + (options?.waitMs ?? DEFAULT_LOCK_WAIT_MS)
  153. let delay = LOCK_RETRY_INITIAL_MS
  154. for (;;) {
  155. try {
  156. await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
  157. break
  158. } catch (error) {
  159. if (!await isLockContention(error, lockPath)) throw error
  160. }
  161. if (Date.now() >= deadline) {
  162. throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`)
  163. }
  164. await new Promise(resolve => setTimeout(resolve, delay))
  165. delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS)
  166. }
  167. try {
  168. return await operation()
  169. } finally {
  170. await rm(lockPath, { force: true })
  171. }
  172. }