@harperfast/harper 5.3.0-alpha.1 → 5.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/bin/cliOperations.ts +12 -0
  2. package/bin/restart.ts +66 -6
  3. package/components/Application.ts +1134 -112
  4. package/components/OptionsWatcher.ts +368 -102
  5. package/components/Scope.ts +20 -1
  6. package/components/componentLoader.ts +43 -4
  7. package/components/deploymentOperations.ts +4 -1
  8. package/components/deploymentRecorder.ts +9 -2
  9. package/components/operations.js +284 -52
  10. package/components/operationsValidation.js +49 -2
  11. package/components/packageComponent.ts +25 -1
  12. package/components/requestRestart.ts +11 -0
  13. package/config/RootConfigWatcher.ts +191 -37
  14. package/config/configReadRetry.ts +62 -0
  15. package/config/configUtils.ts +78 -26
  16. package/config/parseConfigFile.ts +34 -0
  17. package/config/readConfigFileSync.ts +44 -0
  18. package/config/watcherArming.ts +59 -0
  19. package/config-root.schema.json +4 -0
  20. package/dataLayer/harperBridge/ResourceBridge.ts +28 -2
  21. package/dist/bin/cliOperations.js +13 -0
  22. package/dist/bin/cliOperations.js.map +1 -1
  23. package/dist/bin/restart.js +42 -6
  24. package/dist/bin/restart.js.map +1 -1
  25. package/dist/components/Application.d.ts +104 -9
  26. package/dist/components/Application.js +954 -102
  27. package/dist/components/Application.js.map +1 -1
  28. package/dist/components/OptionsWatcher.d.ts +4 -1
  29. package/dist/components/OptionsWatcher.js +378 -104
  30. package/dist/components/OptionsWatcher.js.map +1 -1
  31. package/dist/components/Scope.js +15 -1
  32. package/dist/components/Scope.js.map +1 -1
  33. package/dist/components/componentLoader.js +35 -3
  34. package/dist/components/componentLoader.js.map +1 -1
  35. package/dist/components/deploymentOperations.js +4 -1
  36. package/dist/components/deploymentOperations.js.map +1 -1
  37. package/dist/components/deploymentRecorder.d.ts +4 -2
  38. package/dist/components/deploymentRecorder.js +1 -0
  39. package/dist/components/deploymentRecorder.js.map +1 -1
  40. package/dist/components/operations.d.ts +28 -0
  41. package/dist/components/operations.js +263 -54
  42. package/dist/components/operations.js.map +1 -1
  43. package/dist/components/operationsValidation.js +48 -2
  44. package/dist/components/operationsValidation.js.map +1 -1
  45. package/dist/components/packageComponent.js +24 -0
  46. package/dist/components/packageComponent.js.map +1 -1
  47. package/dist/components/requestRestart.d.ts +1 -0
  48. package/dist/components/requestRestart.js +7 -0
  49. package/dist/components/requestRestart.js.map +1 -1
  50. package/dist/config/RootConfigWatcher.d.ts +2 -0
  51. package/dist/config/RootConfigWatcher.js +189 -35
  52. package/dist/config/RootConfigWatcher.js.map +1 -1
  53. package/dist/config/configReadRetry.d.ts +8 -0
  54. package/dist/config/configReadRetry.js +62 -0
  55. package/dist/config/configReadRetry.js.map +1 -0
  56. package/dist/config/configUtils.d.ts +10 -9
  57. package/dist/config/configUtils.js +63 -27
  58. package/dist/config/configUtils.js.map +1 -1
  59. package/dist/config/parseConfigFile.d.ts +4 -0
  60. package/dist/config/parseConfigFile.js +35 -0
  61. package/dist/config/parseConfigFile.js.map +1 -0
  62. package/dist/config/readConfigFileSync.d.ts +1 -0
  63. package/dist/config/readConfigFileSync.js +47 -0
  64. package/dist/config/readConfigFileSync.js.map +1 -0
  65. package/dist/config/watcherArming.d.ts +15 -0
  66. package/dist/config/watcherArming.js +59 -0
  67. package/dist/config/watcherArming.js.map +1 -0
  68. package/dist/dataLayer/harperBridge/ResourceBridge.js +21 -2
  69. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  70. package/dist/index.d.ts +1 -0
  71. package/dist/index.js +4 -1
  72. package/dist/index.js.map +1 -1
  73. package/dist/json/systemSchema.json +3 -0
  74. package/dist/resources/DatabaseTransaction.d.ts +25 -0
  75. package/dist/resources/DatabaseTransaction.js +224 -18
  76. package/dist/resources/DatabaseTransaction.js.map +1 -1
  77. package/dist/resources/LMDBTransaction.d.ts +2 -1
  78. package/dist/resources/LMDBTransaction.js +22 -3
  79. package/dist/resources/LMDBTransaction.js.map +1 -1
  80. package/dist/resources/PrimaryRocksDatabase.js +22 -6
  81. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  82. package/dist/resources/RecordEncoder.d.ts +1 -1
  83. package/dist/resources/RecordEncoder.js +21 -5
  84. package/dist/resources/RecordEncoder.js.map +1 -1
  85. package/dist/resources/Resource.js +97 -13
  86. package/dist/resources/Resource.js.map +1 -1
  87. package/dist/resources/ResourceInterface.d.ts +8 -0
  88. package/dist/resources/RocksIndexStore.js +2 -1
  89. package/dist/resources/RocksIndexStore.js.map +1 -1
  90. package/dist/resources/RocksTransactionLogStore.d.ts +10 -0
  91. package/dist/resources/RocksTransactionLogStore.js +104 -33
  92. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  93. package/dist/resources/Table.d.ts +58 -7
  94. package/dist/resources/Table.js +1095 -346
  95. package/dist/resources/Table.js.map +1 -1
  96. package/dist/resources/analytics/write.js +10 -3
  97. package/dist/resources/analytics/write.js.map +1 -1
  98. package/dist/resources/auditStore.d.ts +170 -0
  99. package/dist/resources/auditStore.js +457 -11
  100. package/dist/resources/auditStore.js.map +1 -1
  101. package/dist/resources/dataLoader.js +3 -4
  102. package/dist/resources/dataLoader.js.map +1 -1
  103. package/dist/resources/databases.d.ts +16 -13
  104. package/dist/resources/databases.js +624 -176
  105. package/dist/resources/databases.js.map +1 -1
  106. package/dist/resources/derivedIndexRegistry.d.ts +5 -0
  107. package/dist/resources/derivedIndexRegistry.js +68 -0
  108. package/dist/resources/derivedIndexRegistry.js.map +1 -0
  109. package/dist/resources/derivedIndexRuntime.d.ts +215 -0
  110. package/dist/resources/derivedIndexRuntime.js +2027 -0
  111. package/dist/resources/derivedIndexRuntime.js.map +1 -0
  112. package/dist/resources/graphql.js +3 -2
  113. package/dist/resources/graphql.js.map +1 -1
  114. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +102 -9
  115. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +848 -39
  116. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  117. package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
  118. package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
  119. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
  120. package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
  121. package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
  122. package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
  123. package/dist/resources/nodeIdMapping.d.ts +5 -0
  124. package/dist/resources/nodeIdMapping.js +49 -0
  125. package/dist/resources/nodeIdMapping.js.map +1 -1
  126. package/dist/resources/recordLock.d.ts +47 -4
  127. package/dist/resources/recordLock.js +138 -7
  128. package/dist/resources/recordLock.js.map +1 -1
  129. package/dist/resources/recordLockCoordinator.d.ts +557 -0
  130. package/dist/resources/recordLockCoordinator.js +2565 -0
  131. package/dist/resources/recordLockCoordinator.js.map +1 -0
  132. package/dist/resources/replayLogs.js +5 -0
  133. package/dist/resources/replayLogs.js.map +1 -1
  134. package/dist/resources/replicatedApplyFailure.d.ts +16 -0
  135. package/dist/resources/replicatedApplyFailure.js +63 -0
  136. package/dist/resources/replicatedApplyFailure.js.map +1 -0
  137. package/dist/resources/scheduler/scheduler.js +3 -3
  138. package/dist/resources/scheduler/scheduler.js.map +1 -1
  139. package/dist/resources/search.d.ts +10 -4
  140. package/dist/resources/search.js +160 -40
  141. package/dist/resources/search.js.map +1 -1
  142. package/dist/resources/tracked.d.ts +5 -1
  143. package/dist/resources/tracked.js +74 -23
  144. package/dist/resources/tracked.js.map +1 -1
  145. package/dist/security/jsLoader.js +6 -4
  146. package/dist/security/jsLoader.js.map +1 -1
  147. package/dist/server/REST.js +33 -2
  148. package/dist/server/REST.js.map +1 -1
  149. package/dist/server/http.d.ts +5 -1
  150. package/dist/server/http.js +34 -2
  151. package/dist/server/http.js.map +1 -1
  152. package/dist/server/serverHelpers/Headers.d.ts +2 -0
  153. package/dist/server/serverHelpers/Headers.js +6 -0
  154. package/dist/server/serverHelpers/Headers.js.map +1 -1
  155. package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
  156. package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
  157. package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
  158. package/dist/server/serverHelpers/Request.d.ts +5 -10
  159. package/dist/server/serverHelpers/Request.js +38 -136
  160. package/dist/server/serverHelpers/Request.js.map +1 -1
  161. package/dist/server/serverHelpers/contentTypes.d.ts +2 -0
  162. package/dist/server/serverHelpers/contentTypes.js +189 -15
  163. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  164. package/dist/server/serverHelpers/serverUtilities.js +96 -17
  165. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  166. package/dist/server/storageReclamation.js +1 -1
  167. package/dist/server/storageReclamation.js.map +1 -1
  168. package/dist/server/threads/isolatedApplications.d.ts +47 -0
  169. package/dist/server/threads/isolatedApplications.js +171 -0
  170. package/dist/server/threads/isolatedApplications.js.map +1 -0
  171. package/dist/server/threads/logRotationTransport.d.ts +1 -0
  172. package/dist/server/threads/logRotationTransport.js +33 -0
  173. package/dist/server/threads/logRotationTransport.js.map +1 -0
  174. package/dist/server/threads/manageThreads.d.ts +64 -6
  175. package/dist/server/threads/manageThreads.js +261 -12
  176. package/dist/server/threads/manageThreads.js.map +1 -1
  177. package/dist/server/threads/socketRouter.d.ts +1 -0
  178. package/dist/server/threads/socketRouter.js +196 -13
  179. package/dist/server/threads/socketRouter.js.map +1 -1
  180. package/dist/server/threads/threadServer.js +30 -7
  181. package/dist/server/threads/threadServer.js.map +1 -1
  182. package/dist/utility/errors/hdbError.d.ts +24 -0
  183. package/dist/utility/errors/hdbError.js +58 -1
  184. package/dist/utility/errors/hdbError.js.map +1 -1
  185. package/dist/utility/hdbTerms.d.ts +2 -0
  186. package/dist/utility/hdbTerms.js +2 -0
  187. package/dist/utility/hdbTerms.js.map +1 -1
  188. package/dist/utility/logging/harper_logger.js +217 -38
  189. package/dist/utility/logging/harper_logger.js.map +1 -1
  190. package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
  191. package/dist/utility/logging/logGenerationCoordinator.js +184 -0
  192. package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
  193. package/dist/utility/logging/logRotation.d.ts +46 -0
  194. package/dist/utility/logging/logRotation.js +365 -0
  195. package/dist/utility/logging/logRotation.js.map +1 -0
  196. package/dist/utility/logging/logRotator.d.ts +1 -1
  197. package/dist/utility/logging/logRotator.js +172 -92
  198. package/dist/utility/logging/logRotator.js.map +1 -1
  199. package/dist/utility/npmUtilities.js +6 -4
  200. package/dist/utility/npmUtilities.js.map +1 -1
  201. package/dist/utility/watcherFallback.d.ts +0 -45
  202. package/dist/utility/watcherFallback.js +1 -125
  203. package/dist/utility/watcherFallback.js.map +1 -1
  204. package/dist/validation/configValidator.js +6 -3
  205. package/dist/validation/configValidator.js.map +1 -1
  206. package/index.ts +6 -0
  207. package/json/systemSchema.json +3 -0
  208. package/npm-shrinkwrap.json +131 -41
  209. package/package.json +10 -3
  210. package/resources/DESIGN.md +124 -19
  211. package/resources/DatabaseTransaction.ts +230 -17
  212. package/resources/LMDBTransaction.ts +21 -3
  213. package/resources/PrimaryRocksDatabase.ts +20 -7
  214. package/resources/RecordEncoder.ts +25 -5
  215. package/resources/Resource.ts +97 -13
  216. package/resources/ResourceInterface.ts +8 -0
  217. package/resources/RocksIndexStore.ts +2 -1
  218. package/resources/RocksTransactionLogStore.ts +111 -31
  219. package/resources/Table.ts +1224 -393
  220. package/resources/analytics/write.ts +10 -3
  221. package/resources/auditStore.ts +460 -11
  222. package/resources/dataLoader.ts +3 -4
  223. package/resources/databases.ts +610 -146
  224. package/resources/derivedIndexRegistry.ts +56 -0
  225. package/resources/derivedIndexRuntime.ts +2292 -0
  226. package/resources/graphql.ts +3 -2
  227. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +905 -46
  228. package/resources/indexes/hnswDerivedIndex.ts +531 -0
  229. package/resources/indexes/hnswPlaneBinding.ts +174 -0
  230. package/resources/nodeIdMapping.ts +50 -0
  231. package/resources/recordLock.ts +173 -7
  232. package/resources/recordLockCoordinator.ts +3043 -0
  233. package/resources/replayLogs.ts +5 -0
  234. package/resources/replicatedApplyFailure.ts +77 -0
  235. package/resources/scheduler/scheduler.ts +4 -4
  236. package/resources/search.ts +169 -49
  237. package/resources/tracked.ts +73 -22
  238. package/security/jsLoader.ts +6 -4
  239. package/server/DESIGN.md +11 -0
  240. package/server/REST.ts +36 -3
  241. package/server/http.ts +34 -2
  242. package/server/serverHelpers/Headers.ts +5 -1
  243. package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
  244. package/server/serverHelpers/Request.ts +33 -131
  245. package/server/serverHelpers/contentTypes.ts +188 -15
  246. package/server/serverHelpers/serverUtilities.ts +143 -24
  247. package/server/storageReclamation.ts +2 -2
  248. package/server/threads/isolatedApplications.ts +157 -0
  249. package/server/threads/logRotationTransport.ts +40 -0
  250. package/server/threads/manageThreads.js +254 -12
  251. package/server/threads/socketRouter.ts +217 -11
  252. package/server/threads/threadServer.js +30 -7
  253. package/studio/web/assets/{Chat-BnCBegQz.js → Chat-D3j-1yY1.js} +1 -1
  254. package/studio/web/assets/{FloatingChat-CoDW1ySS.js → FloatingChat-BxJGYcfB.js} +3 -3
  255. package/studio/web/assets/{apiToken-Bwk5BLXW.js → apiToken-CT55oWOe.js} +1 -1
  256. package/studio/web/assets/{applications-DHxGi7JH.js → applications-D9Ct9_vm.js} +1 -1
  257. package/studio/web/assets/{cssMode-s0cWI-_M.js → cssMode-DV8H7VwA.js} +1 -1
  258. package/studio/web/assets/{editor-DNcRHK54.js → editor-uatc0unt.js} +1 -1
  259. package/studio/web/assets/{html-Bdssedlg.js → html-Bm6D6paN.js} +1 -1
  260. package/studio/web/assets/{htmlMode-CoDlJ3fw.js → htmlMode-CEn7tpLG.js} +1 -1
  261. package/studio/web/assets/{index-D6sxmFLR.js → index-BIXW6Pu4.js} +5 -5
  262. package/studio/web/assets/{index.lazy-tmU5BS8s.js → index.lazy-UI7L-Vrk.js} +1 -1
  263. package/studio/web/assets/{javascript-B8meVSTH.js → javascript-CJ0G3AFZ.js} +1 -1
  264. package/studio/web/assets/{jsonMode-DpIPd35T.js → jsonMode-DQADAYEa.js} +1 -1
  265. package/studio/web/assets/{languageServices-C_5FMJzQ.js → languageServices-CAQJXWcI.js} +1 -1
  266. package/studio/web/assets/{lspLanguageFeatures-BIzNBzPK.js → lspLanguageFeatures-CCQ8P5sY.js} +1 -1
  267. package/studio/web/assets/{notifications-CQf18QKb.js → notifications-BbxTU6Aw.js} +1 -1
  268. package/studio/web/assets/{notifications-CvZivSbh.js → notifications-Cvb3P1lB.js} +1 -1
  269. package/studio/web/assets/{profile-DdOwtntb.js → profile-Yyb7gsvL.js} +1 -1
  270. package/studio/web/assets/{regions-n69fwagr.js → regions-OgjGHlU5.js} +1 -1
  271. package/studio/web/assets/{register-PfWTCXWB.js → register-6qwNEOY3.js} +2 -2
  272. package/studio/web/assets/{setComponentFile-Bg6O7X0S.js → setComponentFile-BilDMtgB.js} +1 -1
  273. package/studio/web/assets/{setup-CUx_aUDl.js → setup-J6qJ7OIU.js} +2 -2
  274. package/studio/web/assets/{status-D7BVKqX9.js → status-0RWGcfyD.js} +1 -1
  275. package/studio/web/assets/{toggleHighContrast-DBSyXzMr.js → toggleHighContrast-BIn-vErT.js} +1 -1
  276. package/studio/web/assets/{tsMode-BByKCjBS.js → tsMode-DgUXku4d.js} +1 -1
  277. package/studio/web/assets/{typescript-DDLnLpw9.js → typescript-C9orXcsM.js} +1 -1
  278. package/studio/web/assets/{useEntityRestURL-31CHGaHk.js → useEntityRestURL-BEoXXbUB.js} +1 -1
  279. package/studio/web/assets/{workers-pR3jRY9D.js → workers-JVzSDmgx.js} +1 -1
  280. package/studio/web/assets/{xml-2iRnMhQO.js → xml-Cq-S8S4X.js} +1 -1
  281. package/studio/web/assets/{yaml-Bf92gJpd.js → yaml-sfoRdh1M.js} +1 -1
  282. package/studio/web/index.html +1 -1
  283. package/utility/errors/hdbError.ts +54 -0
  284. package/utility/hdbTerms.ts +2 -0
  285. package/utility/logging/harper_logger.ts +209 -30
  286. package/utility/logging/logGenerationCoordinator.ts +196 -0
  287. package/utility/logging/logRotation.ts +367 -0
  288. package/utility/logging/logRotator.ts +196 -91
  289. package/utility/npmUtilities.ts +6 -4
  290. package/utility/watcherFallback.ts +0 -122
  291. package/validation/configValidator.ts +6 -3
@@ -1,19 +1,30 @@
1
1
  import { FSWatcher } from 'chokidar';
2
- import { readFileSync } from 'node:fs';
3
2
  import { getConfigFilePath } from './configUtils.ts';
3
+ import { readConfigFileSync } from './readConfigFileSync.ts';
4
4
  import { EventEmitter, once } from 'node:events';
5
- import { parse } from 'yaml';
5
+ import { parseConfigFile } from './parseConfigFile.ts';
6
6
  import {
7
7
  POLLING_FALLBACK_OPTIONS,
8
- PartialReadRetry,
9
8
  claimLostNativeWatchError,
10
9
  guardedWatch,
11
- isPartialReadError,
12
10
  isWatcherExhaustionError,
13
11
  warnWatcherFallback,
14
- warnWatcherListenerError,
15
12
  } from '../utility/watcherFallback.ts';
16
13
  import { resolveWatchTarget } from '../utility/watchPath.ts';
14
+ import { errorForLog, loggerWithTag } from '../utility/logging/harper_logger.ts';
15
+ import { ConfigReadRetry } from './configReadRetry.ts';
16
+ import { ArmGate } from './watcherArming.ts';
17
+
18
+ function isMissingFile(error: unknown): boolean {
19
+ return !!error && typeof error === 'object' && (error as NodeJS.ErrnoException).code === 'ENOENT';
20
+ }
21
+
22
+ // `harper_logger` imports this module at its own bottom to break their cycle, so a tagged logger
23
+ // built at module scope would run `loggerWithTag()` before `mainLogger` is initialized.
24
+ let taggedLogger: ReturnType<typeof loggerWithTag> | undefined;
25
+ function logger() {
26
+ return (taggedLogger ??= loggerWithTag('config-watcher'));
27
+ }
17
28
 
18
29
  let sharedWatcher: RootConfigWatcher | undefined;
19
30
 
@@ -34,7 +45,15 @@ export class RootConfigWatcher extends EventEmitter {
34
45
  #usingPolling: boolean;
35
46
  #closed: boolean;
36
47
  #openCount: number = 0;
37
- #partialRead: PartialReadRetry;
48
+ #readCount: number = 0;
49
+ #readRetry: ConfigReadRetry = new ConfigReadRetry();
50
+ #armGate: ArmGate = new ArmGate();
51
+ // The gate above is chokidar's scan finishing; this is the barrier's gate. A terminal outcome
52
+ // opens it without claiming the watch is armed, so the arming re-read still runs afterwards.
53
+ #barrierOpen: boolean = false;
54
+ #configLoaded: boolean = false;
55
+ #readyStaged: boolean = false;
56
+ #readyEmitted: boolean = false;
38
57
  ready: Promise<any[]>;
39
58
 
40
59
  constructor(configFilePath: string = getConfigFilePath()) {
@@ -42,7 +61,6 @@ export class RootConfigWatcher extends EventEmitter {
42
61
  this.#configFilePath = configFilePath;
43
62
  const watchTarget = resolveWatchTarget(this.#configFilePath);
44
63
  this.#watchPath = watchTarget.path;
45
- this.#partialRead = new PartialReadRetry(this.#configFilePath);
46
64
  this.#usingPolling = watchTarget.mustPoll;
47
65
  this.#closed = false;
48
66
  this.ready = once(this, 'ready');
@@ -50,14 +68,57 @@ export class RootConfigWatcher extends EventEmitter {
50
68
  }
51
69
 
52
70
  #openWatcher() {
53
- this.#openCount++;
71
+ const generation = ++this.#openCount;
54
72
  this.#watcher = guardedWatch(this.#watchPath, {
55
73
  persistent: false,
56
74
  ...(this.#usingPolling ? POLLING_FALLBACK_OPTIONS : {}),
57
75
  })
58
76
  .on('add', this.handleChange.bind(this))
59
77
  .on('change', this.handleChange.bind(this))
60
- .on('error', this.handleError.bind(this));
78
+ .on('error', this.handleError.bind(this))
79
+ // Generation-bound: `#armGate.reset()` runs before the failed watcher is closed, so a
80
+ // `ready` still queued on it would arm the gate on the replacement's behalf and the
81
+ // replacement's own `ready` would then be a no-op — leaving its scan window unre-read.
82
+ .on('ready', () => this.#handleArmed(generation));
83
+ }
84
+
85
+ #handleArmed(generation: number) {
86
+ if (this.#closed || generation !== this.#openCount) return;
87
+ this.#armGate.arm(() => this.#markArmed());
88
+ }
89
+
90
+ #markArmed() {
91
+ this.#barrierOpen = true;
92
+ // A write that landed while the watch was unarmed was never reported, and a scan that found
93
+ // no file at all reported nothing either, so arming always re-reads rather than publishing
94
+ // what an earlier read staged — or, with no file, staying pending forever.
95
+ this.#read(true);
96
+ // A read that armed the ladder settles `ready` itself, with the newer config.
97
+ if (!this.#readRetry.pending) this.#emitReady();
98
+ }
99
+
100
+ #emitReady() {
101
+ if (this.#readyEmitted || !this.#barrierOpen || !this.#readyStaged || this.#closed) return;
102
+ this.#readyEmitted = true;
103
+ try {
104
+ this.emit('ready', this.#config);
105
+ } catch (error) {
106
+ logger().warn('A Harper configuration listener failed', errorForLog(error));
107
+ }
108
+ }
109
+
110
+ // `harper_logger.start()` awaits `ready` with no timeout, so every terminal outcome has to
111
+ // settle the barrier. A failed read discards an earlier staged value, but a watcher error keeps
112
+ // it because no later read superseded it. No config is represented by `undefined`, not `{}`:
113
+ // an empty object is a configuration that turns logging off.
114
+ #stageBootFallback(discardStaged = true) {
115
+ if (this.#readyEmitted) return;
116
+ if (discardStaged) {
117
+ this.#config = undefined;
118
+ this.#configLoaded = false;
119
+ }
120
+ this.#readyStaged = true;
121
+ this.#emitReady();
61
122
  }
62
123
 
63
124
  // Test-only: simulate the underlying chokidar watcher emitting an error.
@@ -67,17 +128,26 @@ export class RootConfigWatcher extends EventEmitter {
67
128
  this.handleError(error);
68
129
  }
69
130
 
70
- // Test-only: whether the watcher has fallen back to polling.
71
131
  get _usingPollingForTests(): boolean {
72
132
  return this.#usingPolling;
73
133
  }
74
134
 
75
- // Test-only: number of times the underlying watcher has been (re)opened.
76
135
  get _openCountForTests(): number {
77
136
  return this.#openCount;
78
137
  }
79
138
 
139
+ // Distinguishes a ladder rung from a watcher event.
140
+ get _readCountForTests(): number {
141
+ return this.#readCount;
142
+ }
143
+
144
+ get _armedForTests(): boolean {
145
+ return this.#armGate.armed;
146
+ }
147
+
80
148
  handleError(error: unknown) {
149
+ // A queued chokidar error can land after close(), which has dropped every listener.
150
+ if (this.#closed) return;
81
151
  // See EntryHandler.#handleWatcherError: a lost native watch handle is benign
82
152
  // and must not be surfaced to consumers as a config-watch failure.
83
153
  if (claimLostNativeWatchError(error)) return;
@@ -88,6 +158,9 @@ export class RootConfigWatcher extends EventEmitter {
88
158
  if (!this.#usingPolling) {
89
159
  warnWatcherFallback(this.#configFilePath);
90
160
  this.#usingPolling = true;
161
+ // The generation that just failed no longer speaks for the watch; the replacement
162
+ // arms on its own scan, and re-reads then as the first one did.
163
+ this.#armGate.reset();
91
164
  // Start close() from a microtask, not directly here, so a synchronous throw
92
165
  // can't escape this 'error' listener as an uncaught exception.
93
166
  Promise.resolve()
@@ -98,55 +171,136 @@ export class RootConfigWatcher extends EventEmitter {
98
171
  .then(() => {
99
172
  if (!this.#closed) this.#openWatcher();
100
173
  })
101
- .catch((error) => console.error(`Could not reopen the ${this.#configFilePath} watch on polling:`, error));
174
+ .catch((error) =>
175
+ logger().warn(`Could not reopen the ${this.#configFilePath} watch on polling`, errorForLog(error))
176
+ );
177
+ } else {
178
+ // Already polling — the replacement failed too, or the watch was polling from
179
+ // construction (`mustPoll`) and never had a fallback to take. Either way the branch
180
+ // above reopens only once, so this is the watch's terminal outcome and the barrier
181
+ // has to settle or `harper_logger.start()` awaits it forever.
182
+ this.#barrierOpen = true;
183
+ this.#stageBootFallback(false);
102
184
  }
103
185
  return;
104
186
  }
105
- this.emit('error', error);
187
+ // chokidar may never reach its own `ready` after a scan error, and nothing else would
188
+ // settle the barrier: the error is this read's terminal outcome. The scan is not over
189
+ // though, so the arm gate stays closed and a later `ready` still takes the arming re-read.
190
+ this.#barrierOpen = true;
191
+ this.#stageBootFallback(false);
192
+ // Settling the barrier removed the `error` listener `once(this, 'ready')` attached, and an
193
+ // emit with none left throws the error back into chokidar's dispatch — as does a consumer
194
+ // that throws from its own handler.
195
+ if (this.listenerCount('error') === 0) {
196
+ logger().warn(`The Harper configuration watcher at ${this.#configFilePath} failed`, errorForLog(error));
197
+ return;
198
+ }
199
+ try {
200
+ this.emit('error', error);
201
+ } catch (listenerError) {
202
+ logger().warn('A Harper configuration error listener failed', errorForLog(listenerError));
203
+ }
106
204
  }
107
205
 
108
- // See the descriptor-lifetime invariant on atomicWriteFile (DESIGN.md).
109
206
  handleChange() {
207
+ this.#read(true);
208
+ }
209
+
210
+ // `harper_logger.start()` awaits `ready` with no timeout and the ladder may be the only thing
211
+ // left to settle it, so until then its timer keeps the thread alive rather than letting it
212
+ // drain and exit mid-boot.
213
+ #schedule(): boolean {
214
+ return this.#readRetry.schedule(() => this.#read(false), !this.#readyEmitted);
215
+ }
216
+
217
+ #read(waitForLock: boolean) {
218
+ // A queued chokidar callback can still land after close(), which has already discarded the
219
+ // config and dropped every listener.
220
+ if (this.#closed) return;
221
+ this.#readCount++;
222
+ let data: string;
223
+ try {
224
+ data = readConfigFileSync(this.#configFilePath, waitForLock);
225
+ } catch (error) {
226
+ // A missing file is not a lock — `readConfigFileSync` does not retry it either, and
227
+ // `OptionsWatcher` settles it immediately as the install window. Taking the ladder here
228
+ // would disagree with that and cost `harper_logger.start()` the whole budget on every
229
+ // boot that has no config file (an env-var-only deployment, an empty mounted rootPath).
230
+ if (!isMissingFile(error) && this.#schedule()) return;
231
+ // A ladder armed by an earlier empty read is spent by the time a rung lands on ENOENT,
232
+ // and every other terminal path clears its deadline.
233
+ this.#readRetry.reset();
234
+ logger().warn(
235
+ `Unable to read the Harper configuration file at ${this.#configFilePath}` +
236
+ (this.#configLoaded ? ', continuing with the previously loaded configuration' : '; none has been loaded yet'),
237
+ errorForLog(error)
238
+ );
239
+ this.#stageBootFallback();
240
+ return;
241
+ }
242
+ // See DESIGN.md, "An empty read is a writer mid-write, not an empty config".
243
+ if (!data) {
244
+ if (this.#schedule()) return;
245
+ logger().warn(`The Harper configuration file at ${this.#configFilePath} is empty`);
246
+ this.#stageBootFallback();
247
+ return;
248
+ }
110
249
  let config;
111
- // Only the read and parse are guarded: a listener that throws must not be mistaken for a
112
- // half-written file and replayed.
113
250
  try {
114
- config = parse(readFileSync(this.#configFilePath, 'utf-8'));
251
+ config = parseConfigFile(data, this.#configFilePath);
115
252
  } catch (error) {
116
- // A missing file needs no re-read; anything else may be the file being replaced.
117
- if (isPartialReadError(error)) this.#scheduleReread(error);
253
+ // A read taken mid-write is untrustworthy, not only an empty one: the writer's first
254
+ // `write(2)` can land a prefix of the document, and the event carrying the rest is the one
255
+ // chokidar throttles away. So an unparseable read rides out the same ladder as an empty
256
+ // one, and only a read that parses releases it.
257
+ if (this.#schedule()) return;
258
+ logger().warn((error as Error).message);
259
+ this.#stageBootFallback();
118
260
  return;
119
261
  }
120
- // A snapshot that does not parse to an object is the other shape a half-written file
121
- // takes: `''`, `'\n'` and a truncated document all yield null, and adopting that would
122
- // hand every consumer a config with nothing in it.
262
+ // The third shape a mid-write read takes, and the only one that parses: a truncated
263
+ // document, a lone `\n`, a file that is nothing but comments all yield `null` rather than
264
+ // throwing, and adopting one hands every consumer a config with nothing in it. Same ladder
265
+ // as the two above, and past it the file is empty rather than mid-write.
123
266
  if (!config || typeof config !== 'object') {
124
- this.#scheduleReread();
267
+ if (this.#schedule()) return;
268
+ logger().warn(`The Harper configuration file at ${this.#configFilePath} is empty`);
269
+ this.#stageBootFallback();
270
+ return;
271
+ }
272
+ this.#readRetry.reset();
273
+
274
+ // Before `ready` goes out there is no prior state to have changed *since*.
275
+ this.#configLoaded = true;
276
+ this.#readyStaged = true;
277
+ if (!this.#readyEmitted) {
278
+ this.#config = config;
279
+ this.#emitReady();
125
280
  return;
126
281
  }
127
- this.#partialRead.settled();
128
282
 
129
283
  try {
130
- if (!this.#config) {
131
- this.#config = config;
132
- this.emit('ready', this.#config);
133
- return;
134
- }
135
284
  this.emit('change', (this.#config = config));
136
285
  } catch (error) {
137
- warnWatcherListenerError(this.#configFilePath, error);
286
+ logger().warn('A Harper configuration change listener failed', errorForLog(error));
138
287
  }
139
288
  }
140
289
 
141
- #scheduleReread(error?: unknown) {
142
- if (this.#partialRead.schedule(() => this.handleChange())) return;
143
- this.#partialRead.gaveUp(error);
144
- }
145
-
146
290
  close() {
291
+ // Closing is a terminal outcome too: leaving `ready` pending would hang anything still
292
+ // awaiting the barrier. Through `#emitReady`, so a listener that throws cannot skip the
293
+ // teardown below it and leave the watcher and its arm timer running.
294
+ this.#barrierOpen = true;
295
+ this.#readyStaged = true;
296
+ this.#emitReady();
147
297
  this.#closed = true;
148
- this.#partialRead.cancel();
149
- this.#watcher.close();
298
+ this.#readRetry.cancel();
299
+ this.#armGate.cancel();
300
+ // chokidar's close() is a promise; an unhandled teardown rejection would reach Node as one,
301
+ // on the path whose whole job is to stop caring about this watcher. Same shape as the
302
+ // exhaustion-recovery close above, and as `OptionsWatcher.close`.
303
+ Promise.resolve(this.#watcher.close()).catch(() => {});
150
304
  this.#config = undefined;
151
305
  this.emit('close');
152
306
  this.removeAllListeners();
@@ -0,0 +1,62 @@
1
+ // Why a lock that outlives the reader's budget is retried from a timer, and why both the bound and
2
+ // the backoff are wall clock rather than an attempt count: see "Root config watchers must read
3
+ // synchronously" in DESIGN.md (harper#2191).
4
+ const RETRY_BUDGET_MS = 3_100;
5
+ const INITIAL_DELAY_MS = 100;
6
+ const MAX_DELAY_MS = 1_600;
7
+
8
+ let retryBudgetMs = RETRY_BUDGET_MS;
9
+
10
+ // Test-only, the read-side twin of `atomicWriteFile`'s `retryBudgetMs` option. A case that only
11
+ // needs the ladder *spent* has no way to get there but wall clock, so without this each one sits
12
+ // out the shipped 3.1 s; a dozen of them cost the unit job 40 s. Called with no argument it
13
+ // restores the shipped value, and `configReadRetry.test.js` still spends that value in full, so
14
+ // shortening it elsewhere cannot hide a change to what ships.
15
+ export function _setRetryBudgetForTests(ms: number = RETRY_BUDGET_MS): void {
16
+ retryBudgetMs = ms;
17
+ }
18
+
19
+ export class ConfigReadRetry {
20
+ #timer?: NodeJS.Timeout;
21
+ #deadline?: number;
22
+ // Captured with the deadline: the backoff is a fraction of the budget the deadline came from,
23
+ // so reading the module value again mid-ladder would mis-scale it if a test had changed it.
24
+ #budgetMs: number = retryBudgetMs;
25
+
26
+ // `holdEventLoop` is for a caller whose boot barrier this ladder is the only thing left to
27
+ // settle: an unref'd timer would let the thread drain and exit mid-boot instead.
28
+ schedule(retry: () => void, holdEventLoop: boolean = false): boolean {
29
+ this.cancel();
30
+ const now = performance.now();
31
+ if (this.#deadline === undefined) {
32
+ this.#budgetMs = retryBudgetMs;
33
+ this.#deadline = now + this.#budgetMs;
34
+ }
35
+ const remainingMs = this.#deadline - now;
36
+ if (remainingMs <= 0) {
37
+ this.reset();
38
+ return false;
39
+ }
40
+ const elapsedMs = this.#budgetMs - remainingMs;
41
+ const delayMs = Math.min(Math.max(elapsedMs, INITIAL_DELAY_MS), MAX_DELAY_MS, remainingMs);
42
+ this.#timer = setTimeout(retry, delayMs);
43
+ if (!holdEventLoop) this.#timer.unref();
44
+ return true;
45
+ }
46
+
47
+ get pending(): boolean {
48
+ return this.#timer !== undefined;
49
+ }
50
+
51
+ reset(): void {
52
+ this.cancel();
53
+ this.#deadline = undefined;
54
+ }
55
+
56
+ cancel(): void {
57
+ if (this.#timer) {
58
+ clearTimeout(this.#timer);
59
+ this.#timer = undefined;
60
+ }
61
+ }
62
+ }
@@ -92,22 +92,56 @@ export function getConfigPath(param: string) {
92
92
  return path.resolve(rootPath, value);
93
93
  }
94
94
 
95
- // Write atomically via temp file + rename so readers don't observe a truncated/empty file.
96
- // Temp path includes randomness so two worker threads in the same process (same pid) writing
97
- // in the same millisecond can't collide on the temp name and then race the rename.
98
- //
95
+ // Write atomically via a randomized temp file + rename so readers do not observe partial content
96
+ // and concurrent workers, which share process.pid, do not collide on a temp path.
99
97
  // Windows has no POSIX-style "replace an open file" semantics: rename() fails with
100
- // EPERM/EACCES while another descriptor is open on the destination. The sleep below blocks the
101
- // calling thread, so this can only ride out a holder that releases without needing that
102
- // thread's event loop. A holder on the calling thread would live exactly as long as the budget,
103
- // which is why config readers must not keep a descriptor on this file open across an event-loop
104
- // turn (RootConfigWatcher.handleChange, OptionsWatcher#handleChange).
105
- const RENAME_RETRY_MAX_ATTEMPTS = 12;
98
+ // EPERM/EACCES/EBUSY while another worker or AV holds the destination open. Root config watchers use
99
+ // readConfigFileSync so this blocking retry cannot wait on a read owned by its own worker.
100
+ // The budget is the wall-clock window of the 12-attempt schedule it replaced
101
+ // (10+20+40+80+160+320+500*6), so it stays a deadline rather than an attempt count without
102
+ // widening the stall: this loop blocks the calling worker's event loop, and `set_configuration`
103
+ // reaches it from a live request thread.
104
+ const RENAME_RETRY_BUDGET_MS = 3_630;
105
+ // Secondary guard only: stops a degenerate zero-delay option set from spinning the whole budget.
106
+ const RENAME_RETRY_MAX_ATTEMPTS = 25;
106
107
  const RENAME_RETRY_INITIAL_DELAY_MS = 10;
107
108
  const RENAME_RETRY_MAX_DELAY_MS = 500;
108
- // Never notified; exists only so Atomics.wait can time out (a synchronous, CPU-idle sleep).
109
+ // Never notified; Atomics.wait uses this only as a CPU-idle synchronous sleep.
109
110
  const renameRetrySleepBuffer = new Int32Array(new SharedArrayBuffer(4));
110
111
 
112
+ // Classified by code alone rather than gated to win32 like the read side: `process.platform` does
113
+ // not answer whether this filesystem can replace an open file — WSL drvfs, CIFS/SMB and Docker
114
+ // Desktop bind mounts all report `linux` and return these codes transiently.
115
+ function isRetryableRenameError(code: string): boolean {
116
+ return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
117
+ }
118
+
119
+ type RenameRetryOptions = {
120
+ retryBudgetMs?: number;
121
+ maxRetries?: number;
122
+ initialDelayMs?: number;
123
+ maxDelayMs?: number;
124
+ };
125
+
126
+ type AtomicWriteOptions = RenameRetryOptions & {
127
+ skipIfUnchanged?: boolean;
128
+ };
129
+
130
+ function validateRenameRetryOptions({ retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs }: RenameRetryOptions) {
131
+ const invalidOption =
132
+ !Number.isFinite(retryBudgetMs) ||
133
+ retryBudgetMs < 0 ||
134
+ (!Number.isFinite(maxRetries) && maxRetries !== Infinity) ||
135
+ maxRetries < 0 ||
136
+ !Number.isFinite(initialDelayMs) ||
137
+ initialDelayMs < 0 ||
138
+ !Number.isFinite(maxDelayMs) ||
139
+ maxDelayMs < 0;
140
+ if (invalidOption) {
141
+ throw new RangeError('rename retry options must be non-negative numbers');
142
+ }
143
+ }
144
+
111
145
  // Linux has no libuv mapping for EDQUOT, so a quota-exhausted write surfaces as
112
146
  // `Unknown system error -122` with an unusable `code`; the numeric errno is the portable signal
113
147
  // (EDQUOT is 122 on Linux, 69 on macOS).
@@ -142,12 +176,15 @@ export function atomicWriteFile(
142
176
  filePath,
143
177
  content,
144
178
  {
179
+ retryBudgetMs = RENAME_RETRY_BUDGET_MS,
145
180
  maxRetries = RENAME_RETRY_MAX_ATTEMPTS,
146
181
  initialDelayMs = RENAME_RETRY_INITIAL_DELAY_MS,
147
182
  maxDelayMs = RENAME_RETRY_MAX_DELAY_MS,
148
183
  skipIfUnchanged = false,
149
- } = {}
184
+ }: AtomicWriteOptions = {}
150
185
  ) {
186
+ // Before the temp write, so an option set that can never rename leaves no file behind.
187
+ validateRenameRetryOptions({ retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs });
151
188
  // Opt-in: skipping means no mtime bump, so no watcher event. Only callers that re-derive the
152
189
  // same file every boot want that.
153
190
  if (skipIfUnchanged && matchesFileContent(filePath, content)) return false;
@@ -160,8 +197,10 @@ export function atomicWriteFile(
160
197
  throw err;
161
198
  }
162
199
  try {
163
- renameWithRetry(tempPath, filePath, { maxRetries, initialDelayMs, maxDelayMs });
200
+ renameWithRetry(tempPath, filePath, { retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs });
164
201
  } catch (err) {
202
+ // The temp name carries fresh randomness on every call, so a spent budget would otherwise
203
+ // leave a file nothing else will ever collect.
165
204
  removeTempFile(tempPath);
166
205
  throw err;
167
206
  }
@@ -172,35 +211,48 @@ export function renameWithRetry(
172
211
  fromPath,
173
212
  toPath,
174
213
  {
214
+ retryBudgetMs = RENAME_RETRY_BUDGET_MS,
175
215
  maxRetries = RENAME_RETRY_MAX_ATTEMPTS,
176
216
  initialDelayMs = RENAME_RETRY_INITIAL_DELAY_MS,
177
217
  maxDelayMs = RENAME_RETRY_MAX_DELAY_MS,
178
- } = {}
218
+ }: RenameRetryOptions = {}
179
219
  ) {
220
+ validateRenameRetryOptions({ retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs });
180
221
  let retries = maxRetries;
181
222
  let delayMs = initialDelayMs;
223
+ let retryDeadline;
224
+ let finalAttempt = false;
182
225
  let attempts = 0;
183
- const startedAt = Date.now();
226
+ const startedAt = performance.now();
184
227
  while (true) {
185
228
  try {
186
229
  attempts++;
187
230
  fs.renameSync(fromPath, toPath);
188
231
  return;
189
232
  } catch (err) {
190
- if (retries > 0 && (err.code === 'EPERM' || err.code === 'EACCES')) {
233
+ if (!finalAttempt && retries > 0 && isRetryableRenameError(err.code)) {
191
234
  retries--;
192
- // Sleep synchronously (all call sites are sync) to allow the holder to close the
193
- // file. Atomics.wait yields the thread to the OS instead of spinning the CPU,
194
- // which is what makes a multi-second worst-case budget affordable.
195
- if (delayMs > 0) Atomics.wait(renameRetrySleepBuffer, 0, 0, delayMs);
196
- delayMs = Math.min(delayMs * 2, maxDelayMs);
197
- continue;
235
+ if (retryDeadline === undefined) {
236
+ retryDeadline = performance.now() + retryBudgetMs;
237
+ }
238
+ const remainingBudgetMs = retryDeadline - performance.now();
239
+ if (remainingBudgetMs > 0) {
240
+ // Sleep synchronously (all call sites are sync) to allow the holder to close the
241
+ // file. Atomics.wait yields the thread to the OS instead of spinning the CPU,
242
+ // which is what makes a multi-second worst-case budget affordable.
243
+ const sleepMs = Math.min(delayMs, remainingBudgetMs);
244
+ finalAttempt = sleepMs === remainingBudgetMs;
245
+ if (sleepMs > 0) Atomics.wait(renameRetrySleepBuffer, 0, 0, sleepMs);
246
+ delayMs = Math.min(Math.max(delayMs * 2, RENAME_RETRY_INITIAL_DELAY_MS), maxDelayMs);
247
+ continue;
248
+ }
198
249
  }
199
- // Attempts and elapsed distinguish a holder that never released from one that lost
200
- // a race, and neither survives on the rethrown error.
201
- if (err.code === 'EPERM' || err.code === 'EACCES') {
250
+ // Whether the budget was spent or the code was never retryable is the difference
251
+ // between a holder that never released and a one-off failure, and neither survives on
252
+ // the rethrown error.
253
+ if (isRetryableRenameError(err.code)) {
202
254
  logger.warn(
203
- `Could not replace ${toPath}: ${err.code} after ${attempts} attempts over ${Date.now() - startedAt}ms`
255
+ `Could not replace ${toPath}: ${err.code} after ${attempts} attempts over ${Math.round(performance.now() - startedAt)}ms`
204
256
  );
205
257
  }
206
258
  throw err;
@@ -0,0 +1,34 @@
1
+ import { parse } from 'yaml';
2
+
3
+ // yaml's prettyErrors frames the offending source lines into `message`, and a config file holds
4
+ // credentials. Neither the cause nor the original stack is carried for the same reason.
5
+ export class ConfigParseError extends Error {
6
+ constructor(filePath: string, error: unknown) {
7
+ const { name, code, linePos } = (error ?? {}) as {
8
+ name?: string;
9
+ code?: string;
10
+ linePos?: { line: number; col: number }[];
11
+ };
12
+ const at = linePos?.[0] ? ` at line ${linePos[0].line}, column ${linePos[0].col}` : '';
13
+ super(`Unable to parse the Harper configuration file at ${filePath}: ${code ?? name ?? 'parse failure'}${at}`);
14
+ this.name = 'ConfigParseError';
15
+ }
16
+ }
17
+
18
+ export function parseConfigFile(contents: string, filePath: string): any {
19
+ try {
20
+ // yaml routes warnings through `process.emitWarning` rather than a throw, so a framed
21
+ // warning would reach stderr with the config's own source lines in it, around this scrub.
22
+ return parse(contents, { logLevel: 'error' });
23
+ } catch (error) {
24
+ // Only yaml's own parse errors frame the source into `message`. Anything else is a fault in
25
+ // the parser, where the message is the whole of the debugging context.
26
+ if (!isYamlParseError(error)) throw error;
27
+ throw new ConfigParseError(filePath, error);
28
+ }
29
+ }
30
+
31
+ function isYamlParseError(error: unknown): boolean {
32
+ const { name, linePos } = (error ?? {}) as { name?: string; linePos?: unknown };
33
+ return linePos !== undefined || name === 'YAMLParseError';
34
+ }
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ // Why root config reads must not be async, and why this budget is shared rather than per call:
4
+ // see "Root config watchers must read synchronously" in DESIGN.md (harper#2191).
5
+ const READ_RETRY_BUDGET_MS = 500;
6
+ const READ_RETRY_INITIAL_DELAY_MS = 10;
7
+ const READ_RETRY_MAX_DELAY_MS = 100;
8
+ const readRetrySleepBuffer = new Int32Array(new SharedArrayBuffer(4));
9
+ const retryDeadlines = new Map<string, number>();
10
+
11
+ // A caller that already owns a retry ladder passes `waitForLock` false, so one lock costs one
12
+ // blocking window in total rather than one per rung.
13
+ export function readConfigFileSync(filePath: string, waitForLock: boolean = true): string {
14
+ let delayMs = READ_RETRY_INITIAL_DELAY_MS;
15
+ while (true) {
16
+ try {
17
+ const contents = readFileSync(filePath, 'utf-8');
18
+ retryDeadlines.delete(filePath);
19
+ return contents;
20
+ } catch (error) {
21
+ const remainingBudgetMs = waitForLock && isSharingViolation(error) ? remainingRetryBudgetMs(filePath) : 0;
22
+ if (remainingBudgetMs <= 0) throw error;
23
+ Atomics.wait(readRetrySleepBuffer, 0, 0, Math.min(delayMs, remainingBudgetMs));
24
+ delayMs = Math.min(delayMs * 2, READ_RETRY_MAX_DELAY_MS);
25
+ }
26
+ }
27
+ }
28
+
29
+ function isSharingViolation(error: unknown): boolean {
30
+ if (process.platform !== 'win32') return false;
31
+ const code = (error as { code?: string } | null)?.code;
32
+ return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
33
+ }
34
+
35
+ function remainingRetryBudgetMs(filePath: string): number {
36
+ const now = performance.now();
37
+ let deadline = retryDeadlines.get(filePath);
38
+ // A deadline more than one budget past its expiry belongs to an earlier burst, not this one.
39
+ if (deadline === undefined || now - deadline > READ_RETRY_BUDGET_MS) {
40
+ deadline = now + READ_RETRY_BUDGET_MS;
41
+ retryDeadlines.set(filePath, deadline);
42
+ }
43
+ return deadline - now;
44
+ }