@harperfast/harper 5.2.0 → 5.2.2

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 (308) hide show
  1. package/bin/copyDb.ts +40 -11
  2. package/bin/harper.ts +20 -51
  3. package/bin/help.ts +216 -0
  4. package/components/Application.ts +236 -46
  5. package/components/ApplicationScope.ts +26 -0
  6. package/components/EntryHandler.ts +410 -105
  7. package/components/RuntimeModuleTracker.ts +189 -0
  8. package/components/Scope.ts +68 -24
  9. package/components/componentLoader.ts +70 -16
  10. package/components/deployLifecycle.ts +119 -33
  11. package/components/deploymentRecorder.ts +102 -11
  12. package/components/mcp/tools/operations.ts +6 -0
  13. package/components/operations.js +8 -6
  14. package/config/configUtils.ts +78 -20
  15. package/config/harperConfigEnvVars.ts +114 -17
  16. package/config-root.schema.json +10 -0
  17. package/dataLayer/harperBridge/ResourceBridge.ts +26 -5
  18. package/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  19. package/dataLayer/hdbInfoController.ts +8 -0
  20. package/dataLayer/schemaDescribe.ts +2 -1
  21. package/dist/bin/copyDb.js +33 -9
  22. package/dist/bin/copyDb.js.map +1 -1
  23. package/dist/bin/harper.d.ts +6 -0
  24. package/dist/bin/harper.js +18 -50
  25. package/dist/bin/harper.js.map +1 -1
  26. package/dist/bin/help.d.ts +8 -0
  27. package/dist/bin/help.js +192 -0
  28. package/dist/bin/help.js.map +1 -0
  29. package/dist/components/Application.d.ts +16 -1
  30. package/dist/components/Application.js +210 -38
  31. package/dist/components/Application.js.map +1 -1
  32. package/dist/components/ApplicationScope.d.ts +7 -0
  33. package/dist/components/ApplicationScope.js +22 -0
  34. package/dist/components/ApplicationScope.js.map +1 -1
  35. package/dist/components/EntryHandler.d.ts +4 -4
  36. package/dist/components/EntryHandler.js +386 -95
  37. package/dist/components/EntryHandler.js.map +1 -1
  38. package/dist/components/RuntimeModuleTracker.d.ts +11 -0
  39. package/dist/components/RuntimeModuleTracker.js +189 -0
  40. package/dist/components/RuntimeModuleTracker.js.map +1 -0
  41. package/dist/components/Scope.d.ts +1 -0
  42. package/dist/components/Scope.js +69 -22
  43. package/dist/components/Scope.js.map +1 -1
  44. package/dist/components/componentLoader.js +69 -10
  45. package/dist/components/componentLoader.js.map +1 -1
  46. package/dist/components/deployLifecycle.d.ts +6 -2
  47. package/dist/components/deployLifecycle.js +109 -31
  48. package/dist/components/deployLifecycle.js.map +1 -1
  49. package/dist/components/deploymentRecorder.d.ts +6 -0
  50. package/dist/components/deploymentRecorder.js +94 -12
  51. package/dist/components/deploymentRecorder.js.map +1 -1
  52. package/dist/components/mcp/tools/operations.js +6 -0
  53. package/dist/components/mcp/tools/operations.js.map +1 -1
  54. package/dist/components/operations.js +8 -6
  55. package/dist/components/operations.js.map +1 -1
  56. package/dist/config/configUtils.d.ts +6 -0
  57. package/dist/config/configUtils.js +78 -18
  58. package/dist/config/configUtils.js.map +1 -1
  59. package/dist/config/harperConfigEnvVars.js +108 -16
  60. package/dist/config/harperConfigEnvVars.js.map +1 -1
  61. package/dist/dataLayer/harperBridge/ResourceBridge.js +12 -5
  62. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  63. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  64. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js.map +1 -1
  65. package/dist/dataLayer/hdbInfoController.js +4 -0
  66. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  67. package/dist/dataLayer/schemaDescribe.js +2 -1
  68. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  69. package/dist/resources/DatabaseTransaction.d.ts +57 -0
  70. package/dist/resources/DatabaseTransaction.js +284 -104
  71. package/dist/resources/DatabaseTransaction.js.map +1 -1
  72. package/dist/resources/ResourceInterface.d.ts +8 -2
  73. package/dist/resources/ResourceInterface.js.map +1 -1
  74. package/dist/resources/Resources.js +22 -4
  75. package/dist/resources/Resources.js.map +1 -1
  76. package/dist/resources/Table.d.ts +8 -6
  77. package/dist/resources/Table.js +158 -31
  78. package/dist/resources/Table.js.map +1 -1
  79. package/dist/resources/analytics/write.js +6 -6
  80. package/dist/resources/analytics/write.js.map +1 -1
  81. package/dist/resources/auditStore.js +27 -1
  82. package/dist/resources/auditStore.js.map +1 -1
  83. package/dist/resources/blob.d.ts +0 -1
  84. package/dist/resources/blob.js +15 -7
  85. package/dist/resources/blob.js.map +1 -1
  86. package/dist/resources/databases.d.ts +22 -5
  87. package/dist/resources/databases.js +118 -9
  88. package/dist/resources/databases.js.map +1 -1
  89. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +15 -2
  90. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +128 -39
  91. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  92. package/dist/resources/jsResource.d.ts +4 -26
  93. package/dist/resources/jsResource.js +5 -59
  94. package/dist/resources/jsResource.js.map +1 -1
  95. package/dist/resources/models/Models.d.ts +11 -1
  96. package/dist/resources/models/Models.js +10 -1
  97. package/dist/resources/models/Models.js.map +1 -1
  98. package/dist/resources/models/backendRegistry.d.ts +9 -0
  99. package/dist/resources/models/backendRegistry.js +10 -0
  100. package/dist/resources/models/backendRegistry.js.map +1 -1
  101. package/dist/resources/models/openaiStream.d.ts +16 -1
  102. package/dist/resources/models/openaiStream.js +113 -21
  103. package/dist/resources/models/openaiStream.js.map +1 -1
  104. package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
  105. package/dist/resources/models/v1/chatCompletions.js +115 -0
  106. package/dist/resources/models/v1/chatCompletions.js.map +1 -0
  107. package/dist/resources/models/v1/embeddings.d.ts +11 -0
  108. package/dist/resources/models/v1/embeddings.js +71 -0
  109. package/dist/resources/models/v1/embeddings.js.map +1 -0
  110. package/dist/resources/models/v1/errors.d.ts +54 -0
  111. package/dist/resources/models/v1/errors.js +130 -0
  112. package/dist/resources/models/v1/errors.js.map +1 -0
  113. package/dist/resources/models/v1/index.d.ts +36 -0
  114. package/dist/resources/models/v1/index.js +75 -0
  115. package/dist/resources/models/v1/index.js.map +1 -0
  116. package/dist/resources/models/v1/models.d.ts +26 -0
  117. package/dist/resources/models/v1/models.js +44 -0
  118. package/dist/resources/models/v1/models.js.map +1 -0
  119. package/dist/resources/models/v1/translation.d.ts +133 -0
  120. package/dist/resources/models/v1/translation.js +298 -0
  121. package/dist/resources/models/v1/translation.js.map +1 -0
  122. package/dist/resources/roles.d.ts +1 -1
  123. package/dist/resources/roles.js +54 -7
  124. package/dist/resources/roles.js.map +1 -1
  125. package/dist/resources/search.d.ts +1 -1
  126. package/dist/resources/search.js +7 -3
  127. package/dist/resources/search.js.map +1 -1
  128. package/dist/security/jsLoader.js +84 -33
  129. package/dist/security/jsLoader.js.map +1 -1
  130. package/dist/security/role.js +4 -0
  131. package/dist/security/role.js.map +1 -1
  132. package/dist/security/superUserGuard.d.ts +7 -0
  133. package/dist/security/superUserGuard.js +23 -0
  134. package/dist/security/superUserGuard.js.map +1 -0
  135. package/dist/security/tokenAuthentication.d.ts +0 -1
  136. package/dist/security/tokenAuthentication.js +6 -2
  137. package/dist/security/tokenAuthentication.js.map +1 -1
  138. package/dist/security/user.d.ts +6 -1
  139. package/dist/security/user.js +23 -1
  140. package/dist/security/user.js.map +1 -1
  141. package/dist/server/http.d.ts +23 -2
  142. package/dist/server/http.js +99 -12
  143. package/dist/server/http.js.map +1 -1
  144. package/dist/server/operationsServer.d.ts +0 -2
  145. package/dist/server/operationsServer.js.map +1 -1
  146. package/dist/server/serverHelpers/multipartParser.js +9 -0
  147. package/dist/server/serverHelpers/multipartParser.js.map +1 -1
  148. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
  149. package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
  150. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
  151. package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
  152. package/dist/server/serverHelpers/registeredOperations.js +14 -13
  153. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  154. package/dist/server/serverHelpers/serverHandlers.js +19 -3
  155. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  156. package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
  157. package/dist/server/serverHelpers/serverUtilities.js +10 -7
  158. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  159. package/dist/server/static.js +57 -18
  160. package/dist/server/static.js.map +1 -1
  161. package/dist/server/storageReclamation.d.ts +17 -0
  162. package/dist/server/storageReclamation.js +90 -6
  163. package/dist/server/storageReclamation.js.map +1 -1
  164. package/dist/server/threads/manageThreads.js +8 -0
  165. package/dist/server/threads/manageThreads.js.map +1 -1
  166. package/dist/server/threads/socketRouter.js +20 -0
  167. package/dist/server/threads/socketRouter.js.map +1 -1
  168. package/dist/server/threads/threadServer.js +12 -0
  169. package/dist/server/threads/threadServer.js.map +1 -1
  170. package/dist/sqlEngine/diff/differential.js +7 -3
  171. package/dist/sqlEngine/diff/differential.js.map +1 -1
  172. package/dist/sqlEngine/parser/normalizer.js +37 -9
  173. package/dist/sqlEngine/parser/normalizer.js.map +1 -1
  174. package/dist/sqlTranslator/index.js +6 -1
  175. package/dist/sqlTranslator/index.js.map +1 -1
  176. package/dist/upgrade/upgradePrompt.d.ts +2 -2
  177. package/dist/upgrade/upgradePrompt.js +22 -3
  178. package/dist/upgrade/upgradePrompt.js.map +1 -1
  179. package/dist/utility/environment/environmentManager.d.ts +11 -0
  180. package/dist/utility/environment/environmentManager.js +154 -0
  181. package/dist/utility/environment/environmentManager.js.map +1 -1
  182. package/dist/utility/errors/commonErrors.d.ts +1 -0
  183. package/dist/utility/errors/commonErrors.js +1 -0
  184. package/dist/utility/errors/commonErrors.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/install/installer.js +32 -0
  189. package/dist/utility/install/installer.js.map +1 -1
  190. package/dist/utility/lmdb/environmentUtility.js +2 -1
  191. package/dist/utility/lmdb/environmentUtility.js.map +1 -1
  192. package/dist/utility/logging/harper_logger.d.ts +7 -0
  193. package/dist/utility/logging/harper_logger.js +57 -24
  194. package/dist/utility/logging/harper_logger.js.map +1 -1
  195. package/npm-shrinkwrap.json +208 -198
  196. package/package.json +10 -8
  197. package/resources/DESIGN.md +1 -0
  198. package/resources/DatabaseTransaction.ts +313 -102
  199. package/resources/ResourceInterface.ts +8 -2
  200. package/resources/Resources.ts +22 -4
  201. package/resources/Table.ts +398 -259
  202. package/resources/analytics/write.ts +22 -20
  203. package/resources/auditStore.ts +32 -1
  204. package/resources/blob.ts +15 -8
  205. package/resources/databases.ts +123 -12
  206. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +119 -45
  207. package/resources/jsResource.ts +5 -62
  208. package/resources/models/Models.ts +14 -1
  209. package/resources/models/backendRegistry.ts +10 -0
  210. package/resources/models/openaiStream.ts +131 -19
  211. package/resources/models/v1/chatCompletions.ts +128 -0
  212. package/resources/models/v1/embeddings.ts +70 -0
  213. package/resources/models/v1/errors.ts +141 -0
  214. package/resources/models/v1/index.ts +72 -0
  215. package/resources/models/v1/models.ts +53 -0
  216. package/resources/models/v1/translation.ts +362 -0
  217. package/resources/roles.ts +67 -7
  218. package/resources/search.ts +8 -3
  219. package/security/jsLoader.ts +84 -30
  220. package/security/role.ts +7 -0
  221. package/security/superUserGuard.ts +20 -0
  222. package/security/tokenAuthentication.ts +6 -3
  223. package/security/user.ts +26 -1
  224. package/server/DESIGN.md +45 -34
  225. package/server/http.ts +100 -13
  226. package/server/operationsServer.ts +0 -2
  227. package/server/serverHelpers/multipartParser.ts +9 -0
  228. package/server/serverHelpers/operationAuthorizationState.ts +11 -0
  229. package/server/serverHelpers/registeredOperations.ts +19 -15
  230. package/server/serverHelpers/serverHandlers.js +20 -3
  231. package/server/serverHelpers/serverUtilities.ts +10 -7
  232. package/server/static.ts +75 -20
  233. package/server/storageReclamation.ts +104 -8
  234. package/server/threads/manageThreads.js +8 -0
  235. package/server/threads/socketRouter.ts +20 -0
  236. package/server/threads/threadServer.js +11 -0
  237. package/sqlTranslator/index.ts +6 -1
  238. package/static/defaultConfig.yaml +2 -0
  239. package/studio/web/assets/{Chat-DoVWScmq.js → Chat-BMUjPklt.js} +5 -5
  240. package/studio/web/assets/{Chat-DoVWScmq.js.map → Chat-BMUjPklt.js.map} +1 -1
  241. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js → FloatingChat-Cbghuxe5.js} +5 -5
  242. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js.map → FloatingChat-Cbghuxe5.js.map} +1 -1
  243. package/studio/web/assets/{apiToken-BUI_04o7.js → apiToken-CkgTQAqc.js} +2 -2
  244. package/studio/web/assets/{apiToken-BUI_04o7.js.map → apiToken-CkgTQAqc.js.map} +1 -1
  245. package/studio/web/assets/{applications-D03NA7wW.js → applications-WvF4NOTN.js} +13 -13
  246. package/studio/web/assets/applications-WvF4NOTN.js.map +1 -0
  247. package/studio/web/assets/chevron-right-anDJCGlp.js +2 -0
  248. package/studio/web/assets/chevron-right-anDJCGlp.js.map +1 -0
  249. package/studio/web/assets/index-CDeqVx71.css +1 -0
  250. package/studio/web/assets/index-Sevt3MVx.js +819 -0
  251. package/studio/web/assets/index-Sevt3MVx.js.map +1 -0
  252. package/studio/web/assets/index.lazy-Dg5YYPGg.js +14 -0
  253. package/studio/web/assets/{index.lazy-Dx3MpyDC.js.map → index.lazy-Dg5YYPGg.js.map} +1 -1
  254. package/studio/web/assets/{jsonMode-Cjj7dtlr.js → jsonMode-CBSEleE7.js} +2 -2
  255. package/studio/web/assets/{jsonMode-Cjj7dtlr.js.map → jsonMode-CBSEleE7.js.map} +1 -1
  256. package/studio/web/assets/languageServices-gxedeeTo.js +2 -0
  257. package/studio/web/assets/languageServices-gxedeeTo.js.map +1 -0
  258. package/studio/web/assets/{notifications-0edoFTsb.js → notifications-Ap84YyQp.js} +2 -2
  259. package/studio/web/assets/{notifications-0edoFTsb.js.map → notifications-Ap84YyQp.js.map} +1 -1
  260. package/studio/web/assets/notifications-vehqyOLM.js +2 -0
  261. package/studio/web/assets/{notifications-CwKhipK7.js.map → notifications-vehqyOLM.js.map} +1 -1
  262. package/studio/web/assets/{pollUnlessForbidden-HHdQZW1N.js → pollUnlessForbidden-BhzefVRn.js} +2 -2
  263. package/studio/web/assets/pollUnlessForbidden-BhzefVRn.js.map +1 -0
  264. package/studio/web/assets/{profile-DUfEPQtx.js → profile-DzDpRLHY.js} +2 -2
  265. package/studio/web/assets/{profile-DUfEPQtx.js.map → profile-DzDpRLHY.js.map} +1 -1
  266. package/studio/web/assets/queryClient-CgtkoiCY.js +2 -0
  267. package/studio/web/assets/queryClient-CgtkoiCY.js.map +1 -0
  268. package/studio/web/assets/regions-DpJ8s7iN.js +2 -0
  269. package/studio/web/assets/regions-DpJ8s7iN.js.map +1 -0
  270. package/studio/web/assets/register-BDy01-q3.js +3 -0
  271. package/studio/web/assets/register-BDy01-q3.js.map +1 -0
  272. package/studio/web/assets/register-CQHRwNP4.js +3 -0
  273. package/studio/web/assets/register-CQHRwNP4.js.map +1 -0
  274. package/studio/web/assets/{setComponentFile-DMPo4UjC.js → setComponentFile-D0aPcI3W.js} +2 -2
  275. package/studio/web/assets/{setComponentFile-DMPo4UjC.js.map → setComponentFile-D0aPcI3W.js.map} +1 -1
  276. package/studio/web/assets/setup-I15FAF9H.js +3 -0
  277. package/studio/web/assets/{setup-B56Oz1_u.js.map → setup-I15FAF9H.js.map} +1 -1
  278. package/studio/web/assets/status-BF1eJshJ.js +62 -0
  279. package/studio/web/assets/status-BF1eJshJ.js.map +1 -0
  280. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js → swagger-ui-react-DwSPXXhl.js} +2 -2
  281. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js.map → swagger-ui-react-DwSPXXhl.js.map} +1 -1
  282. package/studio/web/assets/{textarea-DhLMZ3PA.js → table-DpOjNQHt.js} +2 -10
  283. package/studio/web/assets/table-DpOjNQHt.js.map +1 -0
  284. package/studio/web/assets/{tsMode-CrHCRjTK.js → tsMode-Bu51A-VG.js} +2 -2
  285. package/studio/web/assets/{tsMode-CrHCRjTK.js.map → tsMode-Bu51A-VG.js.map} +1 -1
  286. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js → useEntityRestURL-DrV1zMrN.js} +2 -2
  287. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js.map → useEntityRestURL-DrV1zMrN.js.map} +1 -1
  288. package/studio/web/index.html +6 -7
  289. package/upgrade/upgradePrompt.ts +22 -3
  290. package/utility/environment/environmentManager.ts +152 -0
  291. package/utility/errors/commonErrors.ts +2 -0
  292. package/utility/hdbTerms.ts +2 -0
  293. package/utility/install/installer.ts +37 -0
  294. package/utility/lmdb/environmentUtility.ts +2 -1
  295. package/utility/logging/harper_logger.ts +57 -26
  296. package/studio/web/assets/applications-D03NA7wW.js.map +0 -1
  297. package/studio/web/assets/index-Bh_CNAHr.js +0 -809
  298. package/studio/web/assets/index-Bh_CNAHr.js.map +0 -1
  299. package/studio/web/assets/index-C8Mv9kHV.css +0 -1
  300. package/studio/web/assets/index.lazy-Dx3MpyDC.js +0 -14
  301. package/studio/web/assets/notifications-CwKhipK7.js +0 -2
  302. package/studio/web/assets/pollUnlessForbidden-HHdQZW1N.js.map +0 -1
  303. package/studio/web/assets/queryClient-CbA8wM7J.js +0 -2
  304. package/studio/web/assets/queryClient-CbA8wM7J.js.map +0 -1
  305. package/studio/web/assets/setup-B56Oz1_u.js +0 -3
  306. package/studio/web/assets/status-BAod7p3o.js +0 -62
  307. package/studio/web/assets/status-BAod7p3o.js.map +0 -1
  308. package/studio/web/assets/textarea-DhLMZ3PA.js.map +0 -1
@@ -9,25 +9,39 @@
9
9
  //
10
10
  // This module solves that by broadcasting a structured "deploy:start" /
11
11
  // "deploy:end" signal to every Harper thread. Each thread's deploy emitter
12
- // fires locally so Scopes (and any plugin subscribers) can react: Scopes close
13
- // their EntryHandlers on start, recreate them on end. The result is that
12
+ // fires locally so Scopes (and any plugin subscribers) can react: Scopes pause
13
+ // their EntryHandlers on start and resume them on end. The result is that
14
14
  // during a deploy, no watcher fires events for the deployed component, and
15
- // after the deploy the recreated watcher emits one fresh add per current file
16
- // collapsed by the existing restart debounce into a single restart.
15
+ // after the deploy the watcher compares the new tree with its retained snapshot
16
+ // and emits the same logical add/change/unlink events consumers saw before #1806.
17
17
  //
18
- // State sharing across threads is intentionally narrow: the local Set of
19
- // in-flight component names is rebuilt from the broadcast stream. Plugins that
20
- // need to gate their own work on deploy progress import `deployLifecycle` and
21
- // listen to the events directly.
18
+ // State sharing across threads is intentionally narrow: local deployment-owner
19
+ // records are rebuilt from the broadcast stream and reclaimed when their owner
20
+ // thread exits. Plugins that need to gate their own work on deploy progress
21
+ // import `deployLifecycle` and listen to the events directly.
22
22
 
23
23
  import { EventEmitter } from 'node:events';
24
- import { isMainThread } from 'node:worker_threads';
25
- import { broadcast, broadcastWithAcknowledgement, onMessageByType } from '../server/threads/manageThreads.js';
24
+ import { randomUUID } from 'node:crypto';
25
+ import { setTimeout as delay } from 'node:timers/promises';
26
+ import { isMainThread, threadId } from 'node:worker_threads';
27
+ import {
28
+ broadcast,
29
+ broadcastWithAcknowledgement,
30
+ isThreadRunning,
31
+ onMessageByType,
32
+ onThreadExit,
33
+ } from '../server/threads/manageThreads.js';
34
+ import harperLogger from '../utility/logging/harper_logger.ts';
26
35
 
27
36
  const DEPLOY_LIFECYCLE_MSG = 'harper:deploy:lifecycle';
28
37
 
29
38
  export type DeployPhase = 'start' | 'end';
30
- export type DeployLifecycleEvent = { name: string; phase: DeployPhase };
39
+ export type DeployLifecycleEvent = {
40
+ name: string;
41
+ phase: DeployPhase;
42
+ deploymentId?: string;
43
+ ownerThreadId?: number;
44
+ };
31
45
 
32
46
  type DeployLifecycleEventsMap = {
33
47
  'deploy:start': [componentName: string];
@@ -35,44 +49,109 @@ type DeployLifecycleEventsMap = {
35
49
  };
36
50
 
37
51
  class DeployLifecycle extends EventEmitter<DeployLifecycleEventsMap> {
38
- // Ref-counts in-flight deploys per component. Concurrent deploys of the
39
- // same name (rare, but possible if an operator queues two before the first
40
- // resolves) overlap: 0→1 fires deploy:start, 1→0 fires deploy:end. Any
41
- // intermediate increment/decrement is silent so watchers aren't toggled
42
- // out from under an active deploy by a peer ending early.
43
- #inFlight = new Map<string, number>();
52
+ #deployments = new Map<string, { name: string; ownerThreadId: number }>();
53
+ #byComponent = new Map<string, Set<string>>();
54
+ #deadOwners = new Set<number>();
55
+ #legacyDeployments = new Map<string, string[]>();
56
+ #legacySequence = 0;
44
57
 
45
58
  isDeployInFlight(componentName: string): boolean {
46
- return (this.#inFlight.get(componentName) ?? 0) > 0;
59
+ return (this.#byComponent.get(componentName)?.size ?? 0) > 0;
47
60
  }
48
61
 
49
62
  // Process a deploy lifecycle event in-process. Called both from the
50
63
  // broadcast receiver (for events originating on another thread) and from
51
64
  // the broadcaster (so the originating thread also reacts locally).
52
65
  _handle(event: DeployLifecycleEvent): void {
53
- const current = this.#inFlight.get(event.name) ?? 0;
66
+ const ownerThreadId = event.ownerThreadId ?? threadId;
67
+ let deploymentId = event.deploymentId;
54
68
  if (event.phase === 'start') {
55
- this.#inFlight.set(event.name, current + 1);
56
- if (current === 0) this.emit('deploy:start', event.name);
57
- } else {
58
- const next = Math.max(0, current - 1);
59
- if (next === 0) {
60
- this.#inFlight.delete(event.name);
61
- if (current > 0) this.emit('deploy:end', event.name);
62
- } else {
63
- this.#inFlight.set(event.name, next);
69
+ if (this.#deadOwners.has(ownerThreadId)) return;
70
+ if (!deploymentId) {
71
+ deploymentId = `legacy:${threadId}:${++this.#legacySequence}`;
72
+ const legacy = this.#legacyDeployments.get(event.name) ?? [];
73
+ legacy.push(deploymentId);
74
+ this.#legacyDeployments.set(event.name, legacy);
75
+ }
76
+ if (this.#deployments.has(deploymentId)) return;
77
+ const active = this.#byComponent.get(event.name) ?? new Set<string>();
78
+ this.#deployments.set(deploymentId, { name: event.name, ownerThreadId });
79
+ this.#byComponent.set(event.name, active);
80
+ active.add(deploymentId);
81
+ if (active.size === 1) this.#emitSafely('deploy:start', event.name);
82
+ return;
83
+ }
84
+ if (!deploymentId) deploymentId = this.#legacyDeployments.get(event.name)?.pop();
85
+ if (deploymentId) this.#removeDeployment(deploymentId);
86
+ }
87
+
88
+ _reclaimOwner(ownerThreadId: number): void {
89
+ this.#deadOwners.add(ownerThreadId);
90
+ for (const [deploymentId, deployment] of this.#deployments) {
91
+ if (deployment.ownerThreadId === ownerThreadId) this.#removeDeployment(deploymentId);
92
+ }
93
+ }
94
+
95
+ async _reclaimOwnerAfterTermination(
96
+ ownerThreadId: number,
97
+ waitForTermination: (ownerThreadId: number) => Promise<void> = waitForOwnerTermination
98
+ ): Promise<void> {
99
+ if (ownerThreadId === 0 || ownerThreadId === threadId) return;
100
+ this.#deadOwners.add(ownerThreadId);
101
+ await waitForTermination(ownerThreadId);
102
+ this._reclaimOwner(ownerThreadId);
103
+ }
104
+
105
+ #removeDeployment(deploymentId: string): void {
106
+ const deployment = this.#deployments.get(deploymentId);
107
+ if (!deployment) return;
108
+ this.#deployments.delete(deploymentId);
109
+ const active = this.#byComponent.get(deployment.name);
110
+ if (!active) return;
111
+ active.delete(deploymentId);
112
+ if (active.size === 0) {
113
+ this.#byComponent.delete(deployment.name);
114
+ this.#emitSafely('deploy:end', deployment.name);
115
+ }
116
+ }
117
+
118
+ #emitSafely(event: 'deploy:start' | 'deploy:end', componentName: string): void {
119
+ for (const listener of this.rawListeners(event)) {
120
+ try {
121
+ listener.call(this, componentName);
122
+ } catch (error) {
123
+ harperLogger.error(`Listener for ${event} threw for ${componentName}:`, error);
64
124
  }
65
125
  }
66
126
  }
67
127
 
68
128
  // Test-only: clear in-flight state without firing events.
69
129
  _clearForTests(): void {
70
- this.#inFlight.clear();
130
+ this.#deployments.clear();
131
+ this.#byComponent.clear();
132
+ this.#deadOwners.clear();
133
+ this.#legacyDeployments.clear();
134
+ this.#legacySequence = 0;
71
135
  }
72
136
  }
73
137
 
74
138
  export const deployLifecycle = new DeployLifecycle();
75
139
 
140
+ async function waitForOwnerTermination(ownerThreadId: number): Promise<void> {
141
+ let timeoutReported = false;
142
+ for (;;) {
143
+ try {
144
+ if (!(await isThreadRunning(ownerThreadId))) return;
145
+ } catch (error) {
146
+ if (!timeoutReported) {
147
+ timeoutReported = true;
148
+ harperLogger.warn(`Retrying liveness check for exited thread ${ownerThreadId}:`, error);
149
+ }
150
+ }
151
+ await delay(25, undefined, { ref: false });
152
+ }
153
+ }
154
+
76
155
  let receiverInstalled = false;
77
156
  function ensureReceiver() {
78
157
  if (receiverInstalled) return;
@@ -88,6 +167,11 @@ function ensureReceiver() {
88
167
  // suppress only the main thread's watchers and the worker watchers would keep
89
168
  // firing restart storms (harper#488).
90
169
  ensureReceiver();
170
+ onThreadExit((deadThreadId: number) => {
171
+ void deployLifecycle
172
+ ._reclaimOwnerAfterTermination(deadThreadId)
173
+ .catch((error) => harperLogger.error(`Could not reclaim deployments from thread ${deadThreadId}:`, error));
174
+ });
91
175
 
92
176
  /**
93
177
  * Announce the start of a deploy for `componentName`. Awaits acknowledgement
@@ -98,9 +182,10 @@ ensureReceiver();
98
182
  * component compose correctly (each call must be paired with exactly one
99
183
  * broadcastDeployEnd).
100
184
  */
101
- export async function broadcastDeployStart(componentName: string): Promise<void> {
185
+ export async function broadcastDeployStart(componentName: string): Promise<string> {
102
186
  ensureReceiver();
103
- const event: DeployLifecycleEvent = { name: componentName, phase: 'start' };
187
+ const deploymentId = randomUUID();
188
+ const event: DeployLifecycleEvent = { name: componentName, phase: 'start', deploymentId, ownerThreadId: threadId };
104
189
  deployLifecycle._handle(event); // local thread first
105
190
  let timer: ReturnType<typeof setTimeout> | undefined;
106
191
  try {
@@ -125,6 +210,7 @@ export async function broadcastDeployStart(componentName: string): Promise<void>
125
210
  } finally {
126
211
  if (timer) clearTimeout(timer);
127
212
  }
213
+ return deploymentId;
128
214
  }
129
215
 
130
216
  /**
@@ -135,9 +221,9 @@ export async function broadcastDeployStart(componentName: string): Promise<void>
135
221
  * Must be called exactly once per matching broadcastDeployStart, even if the
136
222
  * deploy errored — typically from a `finally` block.
137
223
  */
138
- export function broadcastDeployEnd(componentName: string): void {
224
+ export function broadcastDeployEnd(componentName: string, deploymentId: string): void {
139
225
  ensureReceiver();
140
- const event: DeployLifecycleEvent = { name: componentName, phase: 'end' };
226
+ const event: DeployLifecycleEvent = { name: componentName, phase: 'end', deploymentId, ownerThreadId: threadId };
141
227
  deployLifecycle._handle(event);
142
228
  try {
143
229
  void broadcast({ type: DEPLOY_LIFECYCLE_MSG, event }, false);
@@ -13,6 +13,7 @@ import { randomUUID } from 'node:crypto';
13
13
  import { createHash } from 'node:crypto';
14
14
  import { Readable, Transform, pipeline } from 'node:stream';
15
15
  import { databases } from '../resources/databases.ts';
16
+ import { transaction, contextStorage } from '../resources/transaction.ts';
16
17
  import { createBlob, isSaving, deleteBlob, BLOB_UNAVAILABLE_STATUS } from '../resources/blob.ts';
17
18
  import * as terms from '../utility/hdbTerms.ts';
18
19
  import type { CredentialReference } from './secretOperations.ts';
@@ -68,6 +69,39 @@ interface CreateOptions {
68
69
  // no-custody transient token.
69
70
  credentials?: CredentialReference[] | null;
70
71
  emitter?: ProgressEmitter;
72
+ // Open-transaction budget (ms) for the payload-ingest write, below. Mirrors the
73
+ // `deployment_timeout` operation parameter already used for the peer-side row/blob waits
74
+ // (DEFAULT_AWAIT_ROW_TIMEOUT_MS) so operators have one existing knob for all of a deploy's
75
+ // size-driven timeouts, rather than a new config surface for this one.
76
+ ingestTimeoutMs?: number;
77
+ }
78
+
79
+ /**
80
+ * Run `write()` in a fresh transaction while preserving audit attribution. Recorder writes must
81
+ * commit independently of a caller's transaction so an older staged row cannot overwrite them;
82
+ * authorization is enforced by the deploy operation rather than these internal writes.
83
+ *
84
+ * `timeoutBudget` is sticky across RocksDB `getReadTxn()` calls, so reads inside `write()` cannot
85
+ * silently restore the generic default. The transaction uses the larger of this budget and the
86
+ * configured global limit.
87
+ */
88
+ function withIsolatedTransaction<T>(write: () => Promise<T>, timeoutMs?: number): Promise<T> {
89
+ const ambient = contextStorage.getStore();
90
+ const context = {
91
+ user: ambient?.user,
92
+ originatingOperation: ambient?.originatingOperation,
93
+ session: ambient?.session,
94
+ signal: ambient?.signal,
95
+ };
96
+ return transaction(context, (txn) => {
97
+ if (timeoutMs != null) {
98
+ // TODO(harper#2057): only extends the RocksDB DatabaseTransaction head; on
99
+ // HARPER_STORAGE_ENGINE=lmdb, Table.txnForContext() chains a separate LMDBTransaction
100
+ // that this budget doesn't reach.
101
+ txn.timeoutBudget = timeoutMs;
102
+ }
103
+ return write();
104
+ });
71
105
  }
72
106
 
73
107
  export class DeploymentRecorder {
@@ -78,10 +112,13 @@ export class DeploymentRecorder {
78
112
  private pendingPut: Promise<void> | null = null;
79
113
  private dirty = false;
80
114
  private sealed = false;
115
+ private flushSuppressed = false;
116
+ private readonly ingestTimeoutMs?: number;
81
117
 
82
- private constructor(deploymentId: string, initial: Record<string, any>) {
118
+ private constructor(deploymentId: string, initial: Record<string, any>, ingestTimeoutMs?: number) {
83
119
  this.deploymentId = deploymentId;
84
120
  this.record = initial;
121
+ this.ingestTimeoutMs = ingestTimeoutMs;
85
122
  }
86
123
 
87
124
  static async create(options: CreateOptions): Promise<DeploymentRecorder> {
@@ -107,7 +144,7 @@ export class DeploymentRecorder {
107
144
  credentials: options.credentials ?? null,
108
145
  error: null,
109
146
  };
110
- const recorder = new DeploymentRecorder(deploymentId, record);
147
+ const recorder = new DeploymentRecorder(deploymentId, record, options.ingestTimeoutMs);
111
148
  await recorder.put();
112
149
  if (options.emitter) {
113
150
  recorder.subscribeTo(options.emitter);
@@ -158,9 +195,8 @@ export class DeploymentRecorder {
158
195
  // the record dirty; the chained continuation issues a follow-up put once the prior one
159
196
  // settles. This keeps event_log writes O(1) puts per burst rather than O(N) per event.
160
197
  private scheduleFlush(): void {
161
- if (this.sealed) {
162
- // Sealed: accumulate state in memory but don't write. finish() does the single
163
- // terminal write. See seal() for why. The emitter still emits live SSE events.
198
+ if (this.sealed || this.flushSuppressed) {
199
+ // Accumulate state until finish() or the active ingest writes it without a competing put.
164
200
  this.dirty = true;
165
201
  return;
166
202
  }
@@ -175,6 +211,7 @@ export class DeploymentRecorder {
175
211
  this.scheduleFlush();
176
212
  }
177
213
  });
214
+ this.pendingPut.catch((error) => logger.warn?.('Failed to persist deployment progress', error));
178
215
  }
179
216
 
180
217
  /**
@@ -191,6 +228,26 @@ export class DeploymentRecorder {
191
228
  * materialized by the time they reach us, so they take the simpler buffer path.
192
229
  */
193
230
  async ingestPayload(source: Readable | Buffer | string): Promise<void> {
231
+ this.flushSuppressed = true;
232
+ try {
233
+ while (this.pendingPut) {
234
+ try {
235
+ await this.pendingPut;
236
+ } catch {
237
+ /* already logged; the ingest write carries the current row state */
238
+ }
239
+ }
240
+ await this.ingestPayloadWithoutFlush(source);
241
+ } finally {
242
+ this.flushSuppressed = false;
243
+ if (this.dirty && !this.sealed) {
244
+ this.dirty = false;
245
+ this.scheduleFlush();
246
+ }
247
+ }
248
+ }
249
+
250
+ private async ingestPayloadWithoutFlush(source: Readable | Buffer | string): Promise<void> {
194
251
  const hash = createHash('sha256');
195
252
 
196
253
  // In-memory sources: the bytes are already resident, so hashing them and creating a
@@ -202,7 +259,10 @@ export class DeploymentRecorder {
202
259
  this.record.payload_blob = createBlob(buffer, { type: 'application/gzip' });
203
260
  this.record.payload_hash = hash.digest('hex');
204
261
  this.record.payload_size = buffer.length;
205
- await this.put();
262
+ // Same blob-durability gate as the streaming path below applies here too (the commit
263
+ // waits for the buffer to land on disk), so a large base64-in-JSON/CBOR body needs the
264
+ // same extended budget.
265
+ await this.put(ingestTransactionTimeoutMs(this.ingestTimeoutMs));
206
266
  return;
207
267
  }
208
268
 
@@ -263,6 +323,16 @@ export class DeploymentRecorder {
263
323
  });
264
324
  const blob = createBlob(tap, { type: 'application/gzip' });
265
325
  this.record.payload_blob = blob;
326
+ // This row's commit is gated on the blob's durable file write (resources/blob.ts,
327
+ // startPreCommitBlobsForRecord's local-write branch) so a peer never sees a row that
328
+ // references a not-yet-durable blob — the write transaction is legitimately held open for
329
+ // as long as the payload takes to land on disk. The deploy system is explicitly designed
330
+ // to carry arbitrarily large, uncapped components (see the module comment above), so that
331
+ // duration scales with payload size, not with anything bounded. Run this one put() under
332
+ // an extended open-time budget instead of the generic storage.maxTransactionOpenTime meant
333
+ // for ordinary short requests (a large-but-otherwise-healthy ingest tripped that generic
334
+ // guard on a loaded self-hosted runner — see the Large Deploy Payload Test regression).
335
+ //
266
336
  // Persisting the row encodes the blob, which synchronously starts the file write that
267
337
  // drains the tap, so `isSaving(blob)` is set as soon as put() is *called*. Await the put,
268
338
  // the source draining (tapDone), and the file flush (saving) together: handing all three
@@ -270,7 +340,7 @@ export class DeploymentRecorder {
270
340
  // a large upload mid-stream (which rejects both tapDone and the blob write) fails the
271
341
  // deploy loudly here instead of leaking an unhandledRejection. This also makes hash/size
272
342
  // correctness independent of the put()/store write timing and of isSaving() being defined.
273
- const putDone = this.put();
343
+ const putDone = this.put(ingestTransactionTimeoutMs(this.ingestTimeoutMs));
274
344
  const saving = isSaving(blob) ?? Promise.resolve();
275
345
  try {
276
346
  await Promise.all([putDone, tapDone, saving]);
@@ -288,7 +358,7 @@ export class DeploymentRecorder {
288
358
  // ensures the null-reference write lands after the original reference write
289
359
  // (so it wins) and the blob's file lock is released before we unlink it.
290
360
  await Promise.allSettled([putDone, saving]);
291
- await this.put();
361
+ await this.put(ingestTransactionTimeoutMs(this.ingestTimeoutMs));
292
362
  deleteBlob(blob);
293
363
  } catch (cleanupError) {
294
364
  logger.warn?.('Failed to clean up partial deploy payload blob', cleanupError);
@@ -301,7 +371,7 @@ export class DeploymentRecorder {
301
371
  this.record.payload_size = blob.size ?? byteCount;
302
372
  // Persist the now-known hash + size. The blob is already saved, so this re-put does
303
373
  // not re-stream — saveBlob short-circuits on the existing fileId.
304
- await this.put();
374
+ await this.put(ingestTransactionTimeoutMs(this.ingestTimeoutMs));
305
375
  }
306
376
 
307
377
  /**
@@ -436,14 +506,14 @@ export class DeploymentRecorder {
436
506
  return this.record;
437
507
  }
438
508
 
439
- private async put(): Promise<void> {
509
+ private async put(timeoutMs?: number): Promise<void> {
440
510
  const table = (databases as any).system?.[terms.SYSTEM_TABLE_NAMES.DEPLOYMENT_TABLE_NAME];
441
511
  if (!table) {
442
512
  // Table missing means the upgrade directive hasn't run yet (or the table got dropped).
443
513
  // We tolerate this — tracking is observability; the deploy itself must still succeed.
444
514
  return;
445
515
  }
446
- await table.put(this.record);
516
+ await withIsolatedTransaction(() => table.put(this.record), timeoutMs);
447
517
  }
448
518
  }
449
519
 
@@ -455,11 +525,32 @@ export class DeploymentRecorder {
455
525
  // via the `deployment_timeout` operation parameter.
456
526
  export const DEFAULT_AWAIT_ROW_TIMEOUT_MS = 120_000;
457
527
 
528
+ // Default open-time budget for the origin's payload-ingest write transaction (ingestPayload,
529
+ // below). Distinct from DEFAULT_AWAIT_ROW_TIMEOUT_MS (that one bounds a *replication* wait;
530
+ // this one bounds *local disk I/O* for a component the deploy system explicitly supports at
531
+ // arbitrarily large, uncapped sizes — see the module comment above and the large-payload
532
+ // regression test). 10 minutes comfortably covers a multi-GB ingest even on a slow or loaded
533
+ // disk while still being a bounded safety net, not a disabled one. Overridable via the same
534
+ // `deployment_timeout` operation parameter as the peer-side waits, so operators sizing for
535
+ // even larger components (or a known-slow disk) have one knob, not two.
536
+ export const DEFAULT_INGEST_TRANSACTION_TIMEOUT_MS = 600_000;
537
+
458
538
  export function coerceTimeoutMs(value: unknown, fallback: number): number {
459
539
  const requested = Number(value);
460
540
  return Number.isFinite(requested) && requested >= 0 ? requested : fallback;
461
541
  }
462
542
 
543
+ // Floored at the default, never lowered by it: `deployment_timeout` also governs the
544
+ // peer-side row/blob wait (awaitDeploymentRow), where 0 means "poll once, don't wait" — a
545
+ // legitimate fast-fail setting for peers that must never also starve the origin's own disk
546
+ // write down to 0ms.
547
+ export function ingestTransactionTimeoutMs(deploymentTimeout: unknown): number {
548
+ return Math.max(
549
+ coerceTimeoutMs(deploymentTimeout, DEFAULT_INGEST_TRANSACTION_TIMEOUT_MS),
550
+ DEFAULT_INGEST_TRANSACTION_TIMEOUT_MS
551
+ );
552
+ }
553
+
463
554
  /**
464
555
  * Peer-side helper — wait for the hdb_deployment row to arrive via table replication,
465
556
  * then return it. The row is committed on origin before `replicateOperation` is
@@ -303,6 +303,12 @@ export function makeOperationToolHandler(operationName: string) {
303
303
  operation: operationName,
304
304
  hdb_user: context.user,
305
305
  };
306
+ // Authorization bypass is internal dispatch state, never client-supplied tool
307
+ // arguments — mirrors handlePostRequest's stripping in serverHandlers.js. Without
308
+ // this, a caller-supplied `bypass_auth`/`bypassAuth` argument would reach handlers
309
+ // (e.g. createTokens) that trust it directly from the body.
310
+ delete body.bypass_auth;
311
+ delete body.bypassAuth;
306
312
  if (STREAMING_OPERATIONS.has(operationName)) {
307
313
  // Reject before the handler runs at all — see STREAMING_OPERATIONS' comment for why
308
314
  // destroying an already-returned stream isn't sufficient cleanup for these producers.
@@ -441,6 +441,10 @@ async function deployComponent(req) {
441
441
  // Reference form only — the rollback source for re-resolving the credential.
442
442
  credentials: credentialReferences.length ? credentialReferences : null,
443
443
  emitter,
444
+ // Reuse the same `deployment_timeout` knob operators already have for the
445
+ // peer-side row/blob waits to size the origin's payload-ingest write
446
+ // transaction too — see DEFAULT_INGEST_TRANSACTION_TIMEOUT_MS in deploymentRecorder.
447
+ ingestTimeoutMs: req.deployment_timeout,
444
448
  });
445
449
  if (recorder) req._deploymentId = recorder.deploymentId;
446
450
 
@@ -646,12 +650,10 @@ async function deployComponent(req) {
646
650
  // deploy checks its own local isNewComponent, since directory state (and therefore
647
651
  // whether the component was already active) can differ per node.
648
652
  //
649
- // An existing, already-active component being redeployed does NOT force a restart
650
- // here: some updates (e.g. static files only) may not need one at all, and when one
651
- // genuinely is needed, that component's already-running file watcher (Scope/
652
- // EntryHandler, see deployLifecycle.ts) independently detects the post-deploy file
653
- // changes and requests the restart itself.
654
- if (application.isNewComponent) {
653
+ // An existing component's watched files are handled by Scope/EntryHandler. Package
654
+ // metadata is deliberately outside most plugin globs, so compare it across the atomic
655
+ // swap as well: a dependency or module-entry change also invalidates loaded code.
656
+ if (application.isNewComponent || application.packageMetadataChanged) {
655
657
  const { requestRestart } = require('./requestRestart.ts');
656
658
  requestRestart();
657
659
  }
@@ -7,7 +7,6 @@ import YAML from 'yaml';
7
7
  import path from 'path';
8
8
  import { threadId } from 'node:worker_threads';
9
9
  import { randomBytes } from 'node:crypto';
10
- import { performance } from 'node:perf_hooks';
11
10
  import isNumber from 'is-number';
12
11
  import propertiesReaderModule from 'properties-reader';
13
12
  import _ from 'lodash';
@@ -50,6 +49,14 @@ let flatDefaultConfigObj;
50
49
  let flatConfigObj;
51
50
  let configObj;
52
51
 
52
+ // Canonical param names that live in CONFIG_PARAM_MAP but do NOT correspond to a path in the
53
+ // harper-config.yaml schema (BOOT_PROP_PARAMS is boot-props-file-only bookkeeping — see its own
54
+ // comment). Splitting one of these on '_' and writing it into the nested configObj tree would
55
+ // silently create a bogus top-level section (e.g. 'settings_path' -> configObj.settings.path),
56
+ // which componentLoader.ts treats as a real component to load, since it iterates every truthy
57
+ // top-level key of the root config.
58
+ const NON_NESTED_CONFIG_PARAMS = new Set<string>(Object.values(hdbTerms.BOOT_PROP_PARAMS));
59
+
53
60
  export function resolvePath(relativePath: string) {
54
61
  if (relativePath?.startsWith('~/')) {
55
62
  return path.join(hdbUtils.getHomeDir(), relativePath.slice(1));
@@ -87,12 +94,17 @@ export function getConfigPath(param: string) {
87
94
  // Every worker thread runs its own RootConfigWatcher (chokidar), so a write on one thread
88
95
  // routinely races a hot-reload read on another; Windows Defender / AV real-time scanning can
89
96
  // hold a similar transient handle. Retry with exponential backoff to ride out the race -
90
- // callers are synchronous, so the wait is a synchronous busy-loop rather than a real sleep.
91
- const RENAME_RETRY_MAX_ATTEMPTS = 8;
97
+ // callers are synchronous, so the wait is a synchronous sleep rather than an async one.
98
+ // The budget must outlast a single AV real-time scan pass (seconds, not hundreds of ms):
99
+ // the previous ~910ms budget was exhausted twice in a row by the same test on a CI runner
100
+ // (harper#2036), so the worst case is now ~3.6s.
101
+ const RENAME_RETRY_MAX_ATTEMPTS = 12;
92
102
  const RENAME_RETRY_INITIAL_DELAY_MS = 10;
93
- const RENAME_RETRY_MAX_DELAY_MS = 200;
103
+ const RENAME_RETRY_MAX_DELAY_MS = 500;
104
+ // Never notified; exists only so Atomics.wait can time out (a synchronous, CPU-idle sleep).
105
+ const renameRetrySleepBuffer = new Int32Array(new SharedArrayBuffer(4));
94
106
 
95
- function atomicWriteFile(
107
+ export function atomicWriteFile(
96
108
  filePath,
97
109
  content,
98
110
  {
@@ -112,11 +124,10 @@ function atomicWriteFile(
112
124
  } catch (err) {
113
125
  if (retries > 0 && (err.code === 'EPERM' || err.code === 'EACCES')) {
114
126
  retries--;
115
- // Sleep synchronously (all call sites are sync) to allow the reader to close the
116
- // file. Uses performance.now() rather than Date.now(): the latter tracks wall-clock
117
- // time and can jump backward (NTP sync), which would turn this into an unbounded spin.
118
- const start = performance.now();
119
- while (performance.now() - start < delayMs) {}
127
+ // Sleep synchronously (all call sites are sync) to allow the holder to close the
128
+ // file. Atomics.wait yields the thread to the OS instead of spinning the CPU,
129
+ // which is what makes a multi-second worst-case budget affordable.
130
+ if (delayMs > 0) Atomics.wait(renameRetrySleepBuffer, 0, 0, delayMs);
120
131
  delayMs = Math.min(delayMs * 2, maxDelayMs);
121
132
  continue;
122
133
  }
@@ -190,8 +201,7 @@ export function createConfigFile(args, skipFsValidation = false) {
190
201
  // Validates config doc and if required sets default values for some parameters.
191
202
  validateConfig(configDoc, skipFsValidation);
192
203
 
193
- const configObj = configDoc.toJSON();
194
- flatConfigObj = flattenConfig(configObj);
204
+ flatConfigObj = setActiveConfig(configDoc.toJSON());
195
205
 
196
206
  // Create new config file and write config doc to it.
197
207
  const hdbRoot = configDoc.getIn(['rootPath']) as string;
@@ -497,9 +507,9 @@ export function initConfig(force = false) {
497
507
 
498
508
  // Validates config doc and if required sets default values for some parameters.
499
509
  validateConfig(configDoc);
500
- const configObj = configDoc.toJSON();
501
- (server as any).config = configObj;
502
- flatConfigObj = flattenConfig(configObj);
510
+ const parsedConfig = configDoc.toJSON();
511
+ (server as any).config = parsedConfig;
512
+ flatConfigObj = setActiveConfig(parsedConfig);
503
513
 
504
514
  // If config has old version of logrotate enabled let user know it has been deprecated.
505
515
  if (flatConfigObj['logging_rotation_rotate']) {
@@ -687,6 +697,41 @@ export function updateConfigObject(param: string, value: any) {
687
697
  }
688
698
 
689
699
  flatConfigObj[configObjKey.toLowerCase()] = value;
700
+
701
+ // Keep the nested config tree in sync too: componentLoader (root components) and other
702
+ // nested-path readers derive behavior — e.g. a component's network port/protocol — from
703
+ // getConfigObj()'s tree, not the flattened map, so an override that only touched flatConfigObj
704
+ // was invisible to them. configObj is unset only before a live config has ever been installed
705
+ // (setActiveConfig), i.e. during install; don't auto-vivify it, since getConfigObj() reads its
706
+ // falsiness as "not yet initialized" and an empty object would short-circuit that lazy init
707
+ // permanently.
708
+ if (configObj != null && !NON_NESTED_CONFIG_PARAMS.has(configObjKey)) {
709
+ const pathSegments = configObjKey.split('_');
710
+ let node = configObj;
711
+ for (let i = 0; i < pathSegments.length - 1; i++) {
712
+ const segment = pathSegments[i];
713
+ if (node[segment] === undefined) {
714
+ // Auto-vivifying ancestors only to delete the leaf would leave empty sections behind,
715
+ // which componentLoader.ts would treat as components to load.
716
+ if (value === undefined) return;
717
+ node[segment] = {};
718
+ } else if (typeof node[segment] !== 'object' || node[segment] === null) {
719
+ // A legacy scalar shorthand for this key (e.g. `threads: 4`); descending would replace
720
+ // it with {} out from under other readers. Leaves the two views disagreeing on this
721
+ // param, so say so rather than diverging silently.
722
+ logger.trace(
723
+ `Config param '${configObjKey}' not mirrored into the nested config: '${segment}' holds a scalar value`
724
+ );
725
+ return;
726
+ }
727
+ node = node[segment];
728
+ }
729
+ // squashObj never writes an undefined value as an enumerable key either; root-config readers
730
+ // (e.g. bin/run.ts) iterate configObj's own keys and assume every one holds a real value.
731
+ const leaf = pathSegments[pathSegments.length - 1];
732
+ if (value === undefined) delete node[leaf];
733
+ else node[leaf] = value;
734
+ }
690
735
  }
691
736
 
692
737
  /**
@@ -852,7 +897,7 @@ export function updateConfigValue(
852
897
  }
853
898
  atomicWriteFile(configFileLocation, String(configDoc));
854
899
  if (update_config_obj) {
855
- flatConfigObj = flattenConfig(configDoc.toJSON());
900
+ flatConfigObj = setActiveConfig(configDoc.toJSON());
856
901
  }
857
902
  logger.trace(`Config parameter: ${param} updated with value: ${value}`);
858
903
  }
@@ -871,9 +916,25 @@ function backupConfigFile(configPath, hdbRoot) {
871
916
  }
872
917
  }
873
918
 
919
+ /**
920
+ * Flattens `obj` and installs it as the live config — the nested tree getConfigObj() hands out and
921
+ * updateConfigObject() mirrors overrides into. Only callers that own the live config may do this:
922
+ * flattenConfig() also runs on docs that are discarded moments later (the defaults doc, the
923
+ * user-supplied doc installer.ts reads for HDB_CONFIG), and no reader of configObj can tell one of
924
+ * those from the real thing.
925
+ * @param obj
926
+ * @returns the flattened config
927
+ */
928
+ function setActiveConfig(obj) {
929
+ const flatObj = flattenConfig(obj);
930
+ configObj = obj;
931
+ return flatObj;
932
+ }
933
+
874
934
  const PRESERVED_PROPERTIES = ['databases'];
875
935
  /**
876
936
  * Flattens the JSON version of Harper config with underscores separating each parent/child key.
937
+ * Does NOT install `obj` as the live config — see setActiveConfig() for that.
877
938
  * @param obj
878
939
  * @returns {null}
879
940
  */
@@ -882,10 +943,7 @@ export function flattenConfig(obj) {
882
943
  if (obj?.operationsApi?.network) obj.operationsApi.network = { ...obj.http, ...obj.operationsApi.network };
883
944
  if (obj?.operationsApi) obj.operationsApi.tls = { ...obj.tls, ...obj.operationsApi.tls };
884
945
 
885
- configObj = obj;
886
- const flatObj = squashObj(obj);
887
-
888
- return flatObj;
946
+ return squashObj(obj);
889
947
 
890
948
  function squashObj(obj) {
891
949
  let result = {};