win32.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. /**
  2. * Windows durable namespace helpers for the JSONL backend.
  3. *
  4. * POSIX publishes a newly-created log by creating a directory entry and then
  5. * fsyncing the parent directory. Windows does not expose that parent-directory
  6. * fsync contract through Node, so the Windows path uses the native durable
  7. * namespace primitive instead: create a staging object in the target directory
  8. * and publish it with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without
  9. * replacement or cross-volume copy fallback.
  10. *
  11. * @module dsh-session-persistence-jsonl/win32
  12. */
  13. import { createHash } from 'node:crypto'
  14. import { mkdtemp, rm, stat } from 'node:fs/promises'
  15. import { join, parse, resolve, toNamespacedPath } from 'node:path'
  16. type MoveFileExW = (existing: string, replacement: string, flags: number) => number
  17. type CreateSemaphoreW = (security: null, initial: number, maximum: number, name: string) => number
  18. type WaitForSingleObject = (handle: number, milliseconds: number) => number
  19. type ReleaseSemaphore = (handle: number, count: number, previous: null) => number
  20. type CloseHandle = (handle: number) => number
  21. type GetLastError = () => number
  22. interface Win32Bindings {
  23. moveFileExW: MoveFileExW
  24. createSemaphoreW: CreateSemaphoreW
  25. waitForSingleObject: WaitForSingleObject
  26. releaseSemaphore: ReleaseSemaphore
  27. closeHandle: CloseHandle
  28. getLastError: GetLastError
  29. }
  30. interface Win32ErrnoException extends NodeJS.ErrnoException {
  31. win32Code: number
  32. dest: string
  33. }
  34. const MOVEFILE_WRITE_THROUGH = 0x00000008
  35. const WAIT_OBJECT_0 = 0
  36. const WAIT_TIMEOUT = 0x00000102
  37. const ERROR_FILE_NOT_FOUND = 2
  38. const ERROR_PATH_NOT_FOUND = 3
  39. const ERROR_ACCESS_DENIED = 5
  40. const ERROR_NOT_SAME_DEVICE = 17
  41. const ERROR_SHARING_VIOLATION = 32
  42. const ERROR_FILE_EXISTS = 80
  43. const ERROR_INVALID_NAME = 123
  44. const ERROR_ALREADY_EXISTS = 183
  45. let bindings: Win32Bindings | undefined
  46. /** Load the small Win32 API lazily so non-Windows processes never load Koffi. */
  47. async function win32(): Promise<Win32Bindings> {
  48. if (bindings !== undefined) return bindings
  49. const koffi = (await import('koffi')).default
  50. const kernel32 = koffi.load('kernel32.dll')
  51. bindings = {
  52. moveFileExW: kernel32.func('__stdcall', 'MoveFileExW', 'int', ['str16', 'str16', 'uint']) as MoveFileExW,
  53. createSemaphoreW: kernel32.func('__stdcall', 'CreateSemaphoreW', 'intptr', ['void*', 'int', 'int', 'str16']) as CreateSemaphoreW,
  54. waitForSingleObject: kernel32.func('__stdcall', 'WaitForSingleObject', 'uint', ['intptr', 'uint']) as WaitForSingleObject,
  55. releaseSemaphore: kernel32.func('__stdcall', 'ReleaseSemaphore', 'int', ['intptr', 'int', 'void*']) as ReleaseSemaphore,
  56. closeHandle: kernel32.func('__stdcall', 'CloseHandle', 'int', ['intptr']) as CloseHandle,
  57. getLastError: kernel32.func('__stdcall', 'GetLastError', 'uint', []) as GetLastError,
  58. }
  59. return bindings
  60. }
  61. function errnoCode(win32Code: number): string {
  62. switch (win32Code) {
  63. case ERROR_FILE_NOT_FOUND:
  64. case ERROR_PATH_NOT_FOUND:
  65. return 'ENOENT'
  66. case ERROR_ACCESS_DENIED:
  67. return 'EACCES'
  68. case ERROR_NOT_SAME_DEVICE:
  69. return 'EXDEV'
  70. case ERROR_SHARING_VIOLATION:
  71. return 'EBUSY'
  72. case ERROR_FILE_EXISTS:
  73. case ERROR_ALREADY_EXISTS:
  74. return 'EEXIST'
  75. case ERROR_INVALID_NAME:
  76. return 'EINVAL'
  77. default:
  78. return 'EIO'
  79. }
  80. }
  81. function win32Error(syscall: string, win32Code: number, path: string, dest: string): Win32ErrnoException {
  82. const code = errnoCode(win32Code)
  83. const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path} -> ${dest}`) as Win32ErrnoException
  84. error.code = code
  85. error.errno = win32Code
  86. error.syscall = syscall
  87. error.path = path
  88. error.dest = dest
  89. error.win32Code = win32Code
  90. return error
  91. }
  92. function isENOENT(error: unknown): boolean {
  93. return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
  94. }
  95. function isEEXIST(error: unknown): boolean {
  96. return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
  97. }
  98. async function assertDirectory(path: string): Promise<boolean> {
  99. try {
  100. // A bare drive root is already short, and Node rejects its extended-length
  101. // spelling as EISDIR. Descendants retain the namespace for long-path probes.
  102. const probe = path === parse(path).root ? path : toNamespacedPath(path)
  103. const info = await stat(probe)
  104. if (info.isDirectory()) return true
  105. const error = new Error(`path exists but is not a directory: ${path}`) as NodeJS.ErrnoException
  106. error.code = 'ENOTDIR'
  107. error.path = path
  108. throw error
  109. } catch (error) {
  110. if (isENOENT(error)) return false
  111. throw error
  112. }
  113. }
  114. /**
  115. * Publish `existing` at `replacement` with Windows write-through rename
  116. * semantics. The destination must not already exist; the move must stay within
  117. * the volume (no copy fallback flag is set).
  118. * @param existing - the synced staging path to move.
  119. * @param replacement - the final path, which must not already exist.
  120. */
  121. export async function publishNewFileWin32(existing: string, replacement: string): Promise<void> {
  122. const api = await win32()
  123. const ok = api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), MOVEFILE_WRITE_THROUGH)
  124. if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement)
  125. }
  126. /**
  127. * Acquire the session write lock as a named kernel semaphore (count 1) whose
  128. * name is derived from the canonical lock path. A kernel object never touches
  129. * the filesystem, so readers, searches, and directory removal proceed freely
  130. * while the lock is held; a second acquirer's zero-timeout wait times out
  131. * (`EBUSY`); and when the last handle closes — including on any process
  132. * death — the object is destroyed, so a successor's create starts fresh.
  133. * @param path - the lock file path the name is derived from (case-folded:
  134. * Windows paths are case-insensitive).
  135. * @returns the open semaphore handle, released via {@link releaseLockHandleWin32}.
  136. */
  137. export async function acquireLockHandleWin32(path: string): Promise<number> {
  138. const api = await win32()
  139. const name = `Local\\dsh-session-lock-${createHash('sha256').update(resolve(path).toLowerCase()).digest('hex')}`
  140. const handle = api.createSemaphoreW(null, 1, 1, name)
  141. if (handle === 0) throw win32Error('CreateSemaphoreW', api.getLastError(), path, name)
  142. const wait = api.waitForSingleObject(handle, 0)
  143. if (wait === WAIT_OBJECT_0) return handle
  144. api.closeHandle(handle)
  145. if (wait === WAIT_TIMEOUT) throw win32Error('WaitForSingleObject', ERROR_SHARING_VIOLATION, path, name)
  146. throw win32Error('WaitForSingleObject', api.getLastError(), path, name)
  147. }
  148. /**
  149. * Release a lock from {@link acquireLockHandleWin32}: restore the semaphore
  150. * count and close the handle (the object dies with its last handle).
  151. * @param handle - the open semaphore handle.
  152. */
  153. export async function releaseLockHandleWin32(handle: number): Promise<void> {
  154. const api = await win32()
  155. const released = api.releaseSemaphore(handle, 1, null)
  156. const closed = api.closeHandle(handle)
  157. if (released === 0 || closed === 0) throw win32Error('ReleaseSemaphore', api.getLastError(), `handle:${handle}`, `handle:${handle}`)
  158. }
  159. /**
  160. * Create `target` and its missing ancestors with durable Windows namespace
  161. * publication. Each missing directory is first created as a random staging
  162. * sibling, then moved to its final name with `MOVEFILE_WRITE_THROUGH`; races
  163. * with another creator are accepted only after verifying the winner is a
  164. * directory.
  165. * @param target - the absolute directory path to create durably when absent.
  166. */
  167. export async function ensureDurableDirectoryWin32(target: string): Promise<void> {
  168. const absolute = resolve(target)
  169. const root = parse(absolute).root
  170. await assertDirectory(root)
  171. const segments = absolute.slice(root.length).split(/[\\/]+/).filter(part => part.length > 0)
  172. let current = root
  173. for (const segment of segments) {
  174. const next = join(current, segment)
  175. if (!await assertDirectory(next)) await createLeafDirectoryWin32(current, next)
  176. current = next
  177. }
  178. }
  179. async function createLeafDirectoryWin32(parent: string, target: string): Promise<void> {
  180. // Keep the staging component independent of the target basename so a legal
  181. // 255-byte target component does not make mkdtemp's sibling name too long.
  182. const staging = await mkdtemp(toNamespacedPath(join(parent, '.dsh-mkdir-')))
  183. try {
  184. await publishNewFileWin32(staging, target)
  185. } catch (error) {
  186. await rm(staging, { recursive: true, force: true })
  187. if (isEEXIST(error) && await assertDirectory(target)) return
  188. throw error
  189. }
  190. }