@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,24 +1,24 @@
1
1
  import { type Logger } from '../utility/logging/logger.ts';
2
2
  import { loggerWithTag } from '../utility/logging/harper_logger.ts';
3
3
  import { EventEmitter, once } from 'events';
4
- import yaml from 'yaml';
5
4
  import { type FSWatcher } from 'chokidar';
6
5
  import { readFile } from 'node:fs/promises';
7
- import { readFileSync } from 'node:fs';
8
6
  import { isDeepStrictEqual } from 'util';
9
7
  import { DEFAULT_CONFIG } from './DEFAULT_CONFIG.ts';
10
8
  import { cloneDeep } from 'lodash';
11
9
  import {
12
10
  POLLING_FALLBACK_OPTIONS,
13
- PartialReadRetry,
14
11
  claimLostNativeWatchError,
15
12
  guardedWatch,
16
- isPartialReadError,
17
13
  isWatcherExhaustionError,
18
14
  warnWatcherFallback,
19
15
  } from '../utility/watcherFallback.ts';
20
16
  import { resolveWatchTarget } from '../utility/watchPath.ts';
21
17
  import { overlayRootEnvConfig, isRootConfigFilename } from '../config/harperConfigEnvVars.ts';
18
+ import { readConfigFileSync } from '../config/readConfigFileSync.ts';
19
+ import { parseConfigFile } from '../config/parseConfigFile.ts';
20
+ import { ConfigReadRetry } from '../config/configReadRetry.ts';
21
+ import { ArmGate } from '../config/watcherArming.ts';
22
22
 
23
23
  export interface Config {
24
24
  [key: string]: ConfigValue;
@@ -99,13 +99,22 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
99
99
  #scopedConfig?: ConfigValue;
100
100
  #rootConfig?: Config;
101
101
  #isRootConfig: boolean;
102
+ #synchronousRead: boolean;
103
+ #readRetry: ConfigReadRetry = new ConfigReadRetry();
104
+ #armGate: ArmGate = new ArmGate();
102
105
  #name: string;
103
106
  #logger: Logger;
104
107
  #usingPolling: boolean;
105
108
  #closed: boolean;
106
109
  #openCount: number = 0;
110
+ #readCount: number = 0;
111
+ #readSequence: number = 0;
112
+ #appliedSequence: number = 0;
113
+ #scopeConfigured: boolean = false;
114
+ #armAbsence?: NodeJS.Immediate;
115
+ #readyEmitted: boolean = false;
116
+ #envComposeError: unknown;
107
117
  #pendingReads: Set<Promise<void>> = new Set();
108
- #partialRead: PartialReadRetry;
109
118
  ready: Promise<any[]>;
110
119
 
111
120
  constructor(name: string, filePath: string, logger?: Logger, isRootConfig?: boolean) {
@@ -114,11 +123,12 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
114
123
  this.#filePath = filePath;
115
124
  const watchTarget = resolveWatchTarget(filePath);
116
125
  this.#watchPath = watchTarget.path;
117
- this.#partialRead = new PartialReadRetry(filePath);
118
126
  // Root-config watchers must see runtime env config (HARPER_SET_CONFIG et al.)
119
127
  // even when it hasn't been flushed to disk yet — see #handleChange (#1618).
120
128
  // Application scopes watch their own config.yaml and are never overlaid.
121
- this.#isRootConfig = isRootConfig ?? isRootConfigFilename(filePath);
129
+ const rootConfigFile = isRootConfigFilename(filePath);
130
+ this.#isRootConfig = isRootConfig ?? rootConfigFile;
131
+ this.#synchronousRead = this.#isRootConfig;
122
132
  this.#logger = logger || loggerWithTag(name);
123
133
  this.#usingPolling = watchTarget.mustPoll;
124
134
  this.#closed = false;
@@ -127,7 +137,7 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
127
137
  }
128
138
 
129
139
  #openWatcher() {
130
- this.#openCount++;
140
+ const generation = ++this.#openCount;
131
141
  this.#watcher = guardedWatch(this.#watchPath, {
132
142
  persistent: false,
133
143
  ...(this.#usingPolling ? POLLING_FALLBACK_OPTIONS : {}),
@@ -136,65 +146,121 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
136
146
  .on('change', this.#handleChange.bind(this))
137
147
  .on('error', this.#handleError.bind(this))
138
148
  .on('unlink', this.#handleUnlink.bind(this))
139
- .on('ready', this.#handleChange.bind(this));
149
+ // Generation-bound: `#armGate.reset()` runs before the failed watcher is closed, so a
150
+ // `ready` still queued on it would arm the gate on the replacement's behalf and the
151
+ // replacement's own `ready` would then be a no-op — leaving its scan window unre-read.
152
+ .on('ready', () => this.#handleArmed(generation));
153
+ }
154
+
155
+ // Every root-declared plugin gets its own root-config watcher and each reads synchronously, so
156
+ // each has the unarmed window DESIGN.md's "`ready` means the watcher is armed" describes. The
157
+ // write that lands in it is reported by no event, so only this re-read can deliver it.
158
+ #handleArmed(generation: number) {
159
+ if (this.#closed || generation !== this.#openCount) return;
160
+ this.#armGate.arm(() => this.#read(true, true));
140
161
  }
141
162
 
142
- // Root config only: see the descriptor-lifetime invariant on atomicWriteFile (DESIGN.md).
143
163
  #handleChange() {
144
- if (this.#isRootConfig) {
145
- this.#applyRead(() => readFileSync(this.#filePath, 'utf-8'));
164
+ this.#read(true);
165
+ }
166
+
167
+ // While `ready` is outstanding the ladder is the only thing that can settle it, so its timer has
168
+ // to keep the thread alive; afterwards it must not, or a config file nobody is reading would
169
+ // hold a worker open.
170
+ #schedule(): boolean {
171
+ return this.#readRetry.schedule(() => this.#read(false), !this.#readyEmitted);
172
+ }
173
+
174
+ #read(waitForLock: boolean, arming: boolean = false) {
175
+ // A queued chokidar callback can still land after close(), and by then removeAllListeners()
176
+ // has run — emitting into an EventEmitter with no 'error' listener would throw out of it.
177
+ if (this.#closed) return;
178
+ this.#readCount++;
179
+ if (this.#synchronousRead) {
180
+ try {
181
+ let contents: string;
182
+ try {
183
+ contents = readConfigFileSync(this.#filePath, waitForLock);
184
+ } catch (error) {
185
+ this.#handleReadError(error, arming);
186
+ return;
187
+ }
188
+ this.#applyContents(contents);
189
+ } catch (error) {
190
+ // A listener of what `#applyContents` emitted may have closed the watcher, after
191
+ // which `emit('error')` has no listener left and would throw out of here.
192
+ if (!this.#closed) this.#surfaceFailure(error);
193
+ }
146
194
  return;
147
195
  }
196
+
197
+ // The `#closed` check above cannot cover the completion: close() may land while this read is
198
+ // in flight, and by then its retry state is cancelled and its listeners are gone. Nothing
199
+ // orders these against each other either — chokidar does not await one read before starting
200
+ // the next — so an older one completing last would put the file's previous contents back
201
+ // with no event left to correct it.
202
+ const sequence = ++this.#readSequence;
203
+ // `<=`, not `<`: a deletion supersedes the reads already in flight without being a read of
204
+ // its own, and says so by claiming the sequence they were issued under.
205
+ const outranked = () => this.#closed || sequence <= this.#appliedSequence;
148
206
  const read: Promise<void> = readFile(this.#filePath, 'utf-8')
149
- .then((contents) => this.#applyRead(() => contents))
150
- .catch((error) => this.#recoverOrReport(error))
207
+ .then(
208
+ (contents) => {
209
+ if (outranked()) return;
210
+ this.#appliedSequence = sequence;
211
+ this.#applyContents(contents);
212
+ },
213
+ (error) => {
214
+ if (outranked()) return;
215
+ this.#appliedSequence = sequence;
216
+ this.#handleReadError(error, arming);
217
+ }
218
+ )
219
+ .catch((error) => {
220
+ if (!this.#closed) this.#surfaceFailure(error);
221
+ })
151
222
  .finally(() => {
152
223
  this.#pendingReads.delete(read);
153
224
  });
154
225
  this.#pendingReads.add(read);
155
226
  }
156
227
 
157
- #applyRead(read: () => string) {
228
+ #applyContents(contents: string) {
229
+ // An empty read is a writer's truncate window, not an emptied config, and falling through
230
+ // would emit `remove` — see DESIGN.md, "An empty read is a writer mid-write, not an empty
231
+ // config". Both read paths can land in that window; only the synchronous one is likely to.
232
+ if (!contents) {
233
+ if (this.#schedule()) return;
234
+ // Past the ladder the file is empty rather than mid-write, and an empty file carries no
235
+ // scope: keep what is already applied instead of reading it as a removal, and settle the
236
+ // boot barrier on the defaults rather than leaving `Scope.ready` pending forever.
237
+ this.#logger.warn?.(`Configuration file ${this.#filePath} is empty.`);
238
+ this.#settleUnconfigured();
239
+ return;
240
+ }
158
241
  let parsed;
159
242
  try {
160
- parsed = yaml.parse(read());
243
+ parsed = parseConfigFile(contents, this.#filePath);
161
244
  } catch (error) {
162
- // A read or parse that fails while the file is being replaced is the same event as an
163
- // incomplete one, and #handleReadError's ENOENT arm would answer it with a `remove`
164
- // that restarts the scope. Re-read first; only an exhausted budget means it is real.
165
- this.#recoverOrReport(error);
166
- return;
245
+ // A prefix of the document is as much a mid-write read as an empty one, and the event
246
+ // carrying the rest is the one chokidar throttles away — same ladder, same reason. Past it
247
+ // the file really is malformed, which `#read` routes to `#surfaceFailure`.
248
+ if (!this.#closed && this.#schedule()) return;
249
+ throw error;
167
250
  }
168
- // Tested on the file's own parse, before any env overlay: `''`, `'\n'` and a truncated
169
- // document all parse to null, and an env-configured deployment would otherwise overlay
170
- // one into a valid-looking object and adopt it. A file that is still unusable once the
171
- // budget is spent is taken at face value, so emptying one still reaches `remove`.
251
+ // Judged on the file's own parse, before the overlay below: a truncated document, a lone
252
+ // `\n` and a file of nothing but comments all parse to `null` rather than throwing, and
253
+ // `overlayRootEnvConfig` turns any of them into a non-null object whenever a config env var
254
+ // is set — the norm in containers — so overlaying first would launder a half-written file
255
+ // into a valid-looking env-only config and wipe the file's own options. Past the ladder it
256
+ // is an empty file, which `#applyContents` already keeps rather than reads as a removal.
172
257
  if (!parsed || typeof parsed !== 'object') {
173
- if (this.#partialRead.schedule(() => this.#handleChange())) return;
174
- this.#partialRead.gaveUp();
175
- } else {
176
- this.#partialRead.settled();
177
- }
178
- try {
179
- this.#applyParsed(this.#overlayEnvConfig(parsed));
180
- } catch (error) {
181
- // Applying is past the point where an incomplete file could explain a failure, so a
182
- // listener's throw keeps the error route rather than being retried.
183
- this.emit('error', error);
258
+ if (this.#schedule()) return;
259
+ this.#logger.warn?.(`Configuration file ${this.#filePath} is empty.`);
260
+ this.#settleUnconfigured();
261
+ return;
184
262
  }
185
- }
186
-
187
- #recoverOrReport(error: unknown) {
188
- if (!isPartialReadError(error)) return this.#handleReadError(error);
189
- if (this.#partialRead.schedule(() => this.#handleChange())) return;
190
- // Same give-up as the unusable-parse case, so the budget is restored for the repair: the
191
- // write that fixes the file can itself be read mid-write. The error still takes the
192
- // scope's own route.
193
- this.#partialRead.gaveUp(error);
194
- this.#handleReadError(error);
195
- }
196
-
197
- #overlayEnvConfig(parsed: unknown) {
263
+ this.#readRetry.reset();
198
264
  // The on-disk root config is not guaranteed to include runtime env config at
199
265
  // boot: the file flush races component loading, so a scope's boot-time reads
200
266
  // (e.g. an `enabled` gate in handleApplication) could observe pre-env values
@@ -202,36 +268,43 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
202
268
  // config onto EVERY root-config read so scope.options matches the resolved
203
269
  // view (#1618). Non-root scopes and the no-env-vars case are untouched
204
270
  // (overlayRootEnvConfig is a no-op there).
205
- return this.#isRootConfig ? overlayRootEnvConfig(parsed) : parsed;
206
- }
207
-
208
- #applyParsed(parsed: unknown) {
209
- this.#rootConfig = parsed && typeof parsed === 'object' ? (parsed as Config) : undefined;
271
+ if (this.#isRootConfig) {
272
+ try {
273
+ parsed = overlayRootEnvConfig(parsed);
274
+ } catch (error) {
275
+ this.#envComposeError = error;
276
+ if (this.#readyEmitted) this.#reportEnvComposeFailure();
277
+ else this.#settleUnconfigured();
278
+ return;
279
+ }
280
+ }
281
+ this.#rootConfig = parsed && typeof parsed === 'object' ? parsed : undefined;
210
282
  // If the extension is in the config file
211
283
  if (this.#rootConfig && this.#name in this.#rootConfig) {
212
- // If a config object does not exist
213
- if (!this.#scopedConfig) {
214
- // set it
215
- this.#scopedConfig = this.#rootConfig[this.#name];
216
- // and emit a ready event
217
- this.emit('ready', this.#scopedConfig);
218
- } else {
219
- // Otherwise, merge the new config with the old config
220
- this.#merge(this.#rootConfig[this.#name], this.#scopedConfig);
221
- }
284
+ this.#applyScopedConfig(this.#rootConfig[this.#name]);
222
285
  } else {
223
286
  // Otherwise, if the extension is not in the config file
224
287
  // This means the plugin was removed from the config file
225
- if (this.#scopedConfig) {
226
- // and a config exists, remove it
288
+ // Presence, not truthiness: `myPlugin:` with nothing under it is a configured scope, and
289
+ // deleting that block has to reach `Scope` as a removal like any other.
290
+ if (this.#scopeConfigured) {
291
+ this.#scopeConfigured = false;
227
292
  this.#scopedConfig = undefined;
228
- this.emit('remove');
293
+ this.#emitRemove();
294
+ }
295
+ // The scope may be added back later, but this read is still a terminal outcome: with
296
+ // nothing ever applied, neither branch above emits, and `Scope.ready` would stay pending
297
+ // for a config that read perfectly well. The read succeeded, so only the scope falls back
298
+ // to its default — `#settleUnconfigured`'s full reset would discard the root config this
299
+ // very read produced.
300
+ if (!this.#readyEmitted) {
301
+ this.#scopedConfig = cloneDeep(DEFAULT_CONFIG[this.#name]);
302
+ this.#emitReady(this.#scopedConfig);
229
303
  }
230
- // Otherwise do nothing - the user may add the config back in later
231
304
  }
232
305
  }
233
306
 
234
- #handleReadError(error: unknown) {
307
+ #handleReadError(error: unknown, arming: boolean = false) {
235
308
  // If the config file does not exist
236
309
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
237
310
  // A readFile ENOENT here is the install window (file not written yet) or a
@@ -241,22 +314,110 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
241
314
  // through to the original ENOENT handling with #rootConfig untouched, so a
242
315
  // first boot still emits `ready` (not `remove`, which nothing consumes at
243
316
  // boot → `ready` would hang forever).
317
+ // A ladder armed by an earlier empty read is spent here, and every other terminal path
318
+ // clears its deadline; leaving it armed costs the next mid-write read its whole budget.
319
+ this.#readRetry.reset();
244
320
  if (this.#applyEnvOnlyConfig()) return;
245
321
  // And a config already exists, reset it to the default
246
322
  if (this.#rootConfig) {
323
+ // The arm gate's job is the *write* that landed while the watch was unarmed, and
324
+ // answering its ENOENT with a removal reports the deletion ahead of the `unlink`
325
+ // that would confirm it — on a platform with an arming grace, ahead of chokidar
326
+ // having finished its own teardown, so a config recreated on the strength of that
327
+ // early `remove` lands where its `add` is not observed at all. It cannot simply be
328
+ // dropped either: the unarmed window is exactly where an `unlink` can go missing.
329
+ // So it goes back to the loop once, and chokidar's own `unlink` cancels it.
330
+ if (arming) {
331
+ // Every resolution of the deferral recomposes and reports the env state it finds,
332
+ // so a failure held across it is one that may no longer be true — and the next
333
+ // path to report would attach it to an unrelated event.
334
+ this.#envComposeError = undefined;
335
+ this.#deferAbsenceCheck();
336
+ return;
337
+ }
247
338
  this.#resetConfig();
248
- this.emit('remove');
339
+ this.#emitRemove();
249
340
  } else {
250
341
  // Otherwise, if no config exists, then just set to default and emit ready
251
342
  this.#resetConfig();
252
- this.emit('ready');
343
+ this.#emitReady();
253
344
  }
345
+ this.#reportEnvComposeFailure();
254
346
  return;
255
347
  }
256
- this.emit('error', error);
348
+ // A failure that outlives the read emits no new watcher event when it clears, so without
349
+ // this the scope would hold a stale config until the next write. Both read paths: an
350
+ // application config on SMB or under an editor's replacement returns a transient
351
+ // EBUSY/EIO just the same, and only the read for it blocks — the ladder never does.
352
+ if (!this.#closed && this.#schedule()) return;
353
+ this.#surfaceFailure(error);
354
+ }
355
+
356
+ // A failure before anything has been applied is the boot window, where `error` alone strands the
357
+ // component: `Scope` logs it and componentLoader waits on `Scope.ready` with no timeout. Fall
358
+ // back to the defaults exactly as the ENOENT branch above does — and emit `ready` first, since
359
+ // `error` settles the `once(..., 'ready')` promises both of them await.
360
+ #surfaceFailure(error: unknown) {
361
+ this.#settleUnconfigured();
362
+ this.#emitError(error);
363
+ }
364
+
365
+ // Settling the barrier removes the `error` listener `once(this, 'ready')` attached, and an emit
366
+ // with none left throws the error back at the caller — here, chokidar's dispatch or a retry
367
+ // timer, where nothing can absorb it. A consumer that throws is no different.
368
+ #emitError(error: unknown) {
369
+ if (this.listenerCount('error') === 0) {
370
+ this.#logger.error?.(`The configuration for '${this.#name}' at ${this.#filePath} could not be read`, error);
371
+ return;
372
+ }
373
+ try {
374
+ this.emit('error', error);
375
+ } catch (listenerError) {
376
+ this.#logger.error?.('A configuration error listener failed', listenerError);
377
+ }
378
+ }
379
+
380
+ #emitRemove() {
381
+ try {
382
+ this.emit('remove');
383
+ } catch (error) {
384
+ this.#logger.error?.('A configuration removal listener failed', error);
385
+ }
386
+ }
387
+
388
+ // `#readyEmitted`, not the config's truthiness: a scope absent from a config that read fine
389
+ // leaves `#rootConfig` set with no `ready` behind it, and a later failure would return here
390
+ // with `Scope.ready` still pending.
391
+ #settleUnconfigured() {
392
+ if (this.#readyEmitted) return;
393
+ // A file the watcher cannot use does not unset env config, exactly as on the ENOENT path
394
+ // (#1618) — but the barrier still has to settle, including when composing that env config is
395
+ // itself what failed.
396
+ if (this.#applyEnvOnlyConfig()) {
397
+ if (!this.#readyEmitted) this.#emitReady(this.#scopedConfig);
398
+ } else {
399
+ this.#resetConfig();
400
+ this.#emitReady(this.#scopedConfig);
401
+ }
402
+ // Also on the env-only branch: the caller may be `#handleChange`'s merge-shape failure,
403
+ // whose error composing `undefined` never reproduces.
404
+ this.#reportEnvComposeFailure();
405
+ }
406
+
407
+ #emitReady(...args: [ConfigValue?]) {
408
+ this.#readyEmitted = true;
409
+ try {
410
+ this.emit('ready', ...args);
411
+ } catch (error) {
412
+ // The failure paths emit `ready` from a ladder timer and from chokidar's error dispatch,
413
+ // where a throwing listener is an uncaught exception that takes the worker down.
414
+ this.#logger.error?.('A configuration listener failed', error);
415
+ }
257
416
  }
258
417
 
259
418
  #handleError(error: unknown) {
419
+ // A queued chokidar error can land after close(), which has dropped every listener.
420
+ if (this.#closed) return;
260
421
  // See EntryHandler.#handleWatcherError: a lost native watch handle is benign
261
422
  // and must not be surfaced to consumers as a config-watch failure.
262
423
  if (claimLostNativeWatchError(error)) return;
@@ -267,6 +428,9 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
267
428
  if (!this.#usingPolling) {
268
429
  warnWatcherFallback(this.#filePath);
269
430
  this.#usingPolling = true;
431
+ // The generation that just failed no longer speaks for the watch; the replacement arms
432
+ // on its own scan, and re-reads then as the first one did.
433
+ this.#armGate.reset();
270
434
  // Start close() from a microtask, not directly here, so a synchronous throw
271
435
  // can't escape this 'error' listener as an uncaught exception.
272
436
  Promise.resolve()
@@ -278,13 +442,35 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
278
442
  if (!this.#closed) this.#openWatcher();
279
443
  })
280
444
  .catch((error) => this.#logger.error?.(`Could not reopen the ${this.#filePath} watch on polling:`, error));
445
+ } else {
446
+ // Already polling — the replacement failed too, or the watch was polling from
447
+ // construction (`mustPoll`) and never had a fallback to take. Either way the branch
448
+ // above reopens only once, so no read will ever run: settle the barrier rather than
449
+ // leave `Scope.ready` pending forever.
450
+ this.#settleUnconfigured();
281
451
  }
282
452
  return;
283
453
  }
284
- this.emit('error', new OptionsWatcherConfigFileError(this.#filePath, error));
454
+ // Terminal for this scope: `componentLoader` awaits `Scope.ready` with no timeout, and an
455
+ // `error` emitted while it is pending rejects that barrier instead of settling it — the
456
+ // asymmetry with `RootConfigWatcher.handleError` the boot-barrier contract cannot afford.
457
+ const watcherError = new OptionsWatcherConfigFileError(this.#filePath, error);
458
+ this.#settleUnconfigured();
459
+ this.#emitError(watcherError);
285
460
  }
286
461
 
287
462
  #handleUnlink(path: string) {
463
+ // The deletion settles what a pending read was retrying, and a rung landing after this
464
+ // would find ENOENT and emit a second `remove` at consumers that treat it as teardown.
465
+ // Same for the arming re-read's deferred absence check: this is the event it was waiting on.
466
+ // Cancelling the ladder covers the rung not yet armed; a rung already in flight on the
467
+ // asynchronous path is outranked instead, or its ENOENT would report the same deletion again
468
+ // — after the settle below, as a `remove` that asks `Scope` to restart a scope that just
469
+ // booted on the defaults.
470
+ this.#readRetry.reset();
471
+ this.#appliedSequence = this.#readSequence;
472
+ if (this.#armAbsence) clearImmediate(this.#armAbsence);
473
+ this.#armAbsence = undefined;
288
474
  // A real deletion still leaves env-var config in force: an env-defined scope must
289
475
  // survive it exactly as on the ENOENT read path — same fallback, same error routing
290
476
  // (#1618, #1726 review).
@@ -298,17 +484,23 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
298
484
  `Configuration file ${path} was deleted. Reverting to default configuration. Recreate it to restore the options watcher.`
299
485
  );
300
486
  this.#resetConfig();
301
- this.emit('remove');
487
+ // Before the first `ready` there is nothing to remove and nothing to hear it: `Scope.ready`
488
+ // is still pending, so a `remove` here asks for a restart of a scope that never booted while
489
+ // the `#readRetry.reset()` above cancelled the only thing left to settle the barrier. Same
490
+ // ruling as the ENOENT read path, which already boots on the defaults rather than reporting
491
+ // a removal `ready` would then wait behind forever.
492
+ if (this.#readyEmitted) this.#emitRemove();
493
+ else this.#emitReady(this.#scopedConfig);
494
+ this.#reportEnvComposeFailure();
302
495
  }
303
496
 
304
497
  /**
305
498
  * Shared fallback for the ENOENT read path and `#handleUnlink`: when config env vars
306
- * define this scope, apply the env-only overlay (first application → `ready`; already
307
- * configured → `merge`, never reset). Returns true when the event was handled —
308
- * including the malformed-env case, which routes to `error` like the file-read path
309
- * rather than an unhandled rejection. Returns false (root config untouched) when this
310
- * is not a root config or the env config does not provide the scope, so callers keep
311
- * their own reset semantics.
499
+ * define this scope, apply the env-only overlay (first source application → `ready`;
500
+ * already configured → `merge`, never reset). Returns false — root config untouched, so
501
+ * callers keep their own reset semantics — when this is not a root config, when the env
502
+ * config does not provide the scope, and when composing it threw, which is held for
503
+ * `#reportEnvComposeFailure` rather than emitted here.
312
504
  */
313
505
  #applyEnvOnlyConfig(): boolean {
314
506
  if (!this.#isRootConfig) return false;
@@ -316,25 +508,71 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
316
508
  try {
317
509
  composed = overlayRootEnvConfig(undefined) as Config | undefined;
318
510
  } catch (composeError) {
319
- this.emit('error', composeError);
320
- return true;
511
+ // Env config that cannot be composed is not env config: `false` puts every caller on the
512
+ // path it already takes when there is none, so the barrier still settles on the defaults
513
+ // and a deletion still emits `remove`. The failure is held for `#reportEnvComposeFailure`,
514
+ // which callers run *after* settling — an `error` emitted first settles
515
+ // `once(this, 'ready')` by rejection instead.
516
+ this.#envComposeError = composeError;
517
+ return false;
321
518
  }
322
519
  if (!composed || !(this.#name in composed)) return false;
323
520
  this.#rootConfig = composed;
324
- if (!this.#scopedConfig) {
325
- this.#scopedConfig = composed[this.#name];
326
- this.emit('ready', this.#scopedConfig);
327
- } else {
328
- this.#merge(composed[this.#name], this.#scopedConfig);
329
- }
521
+ this.#applyScopedConfig(composed[this.#name]);
330
522
  return true;
331
523
  }
332
524
 
525
+ #reportEnvComposeFailure() {
526
+ if (this.#envComposeError === undefined) return;
527
+ const error = this.#envComposeError;
528
+ this.#envComposeError = undefined;
529
+ this.#emitError(error);
530
+ }
531
+
532
+ // A scope with no source config takes `ready`, one with a prior source value takes the granular `change`
533
+ // events `#merge` derives. `ready` is emitted more than once: a scope can go back to having no
534
+ // config of its own — see `Scope.#handleOptionsWatcherReady` for what a repeat means there.
535
+ #applyScopedConfig(next: ConfigValue) {
536
+ if (!this.#scopeConfigured) {
537
+ this.#scopeConfigured = true;
538
+ this.#scopedConfig = next;
539
+ this.#emitReady(this.#scopedConfig);
540
+ return;
541
+ }
542
+ if (this.#scopedConfig) {
543
+ return this.#merge(next, this.#scopedConfig);
544
+ }
545
+ // A falsy scope value is still a configured scope — `myPlugin:` with nothing under it is
546
+ // the idiomatic "enable with defaults" — and `#merge` cannot diff from one, because
547
+ // `#setValue` needs a value to walk. Re-reading it is not a transition (every ladder rung
548
+ // and rename-burst re-read would otherwise look like one), but *filling it in* is a change
549
+ // like any other, not the unconfigured → configured `ready` that `Scope` answers with a
550
+ // restart.
551
+ if (isDeepStrictEqual(next, this.#scopedConfig)) return;
552
+ this.#scopedConfig = next;
553
+ this.#emitChange([], next);
554
+ }
555
+
556
+ // Cloned, never aliased: `#merge` writes into `#scopedConfig` in place, so a scope that starts
557
+ // on the defaults and is then configured would otherwise write the app's values into the
558
+ // module-level `DEFAULT_CONFIG` every later reset hands out. And not configured: the six scopes
559
+ // `DEFAULT_CONFIG` names would otherwise have the next read of an unchanged file look like the
560
+ // block being deleted.
333
561
  #resetConfig() {
334
- this.#rootConfig = DEFAULT_CONFIG;
562
+ this.#scopeConfigured = false;
563
+ this.#rootConfig = cloneDeep(DEFAULT_CONFIG);
335
564
  this.#scopedConfig = this.#rootConfig[this.#name];
336
565
  }
337
566
 
567
+ #deferAbsenceCheck() {
568
+ if (this.#armAbsence) return;
569
+ this.#armAbsence = setImmediate(() => {
570
+ this.#armAbsence = undefined;
571
+ if (!this.#closed) this.#read(true);
572
+ });
573
+ this.#armAbsence.unref?.();
574
+ }
575
+
338
576
  /**
339
577
  * This merge algorithm is best thought off as a diff and overwrite.
340
578
  * The new config object will completely overwrite the old config object,
@@ -429,7 +667,7 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
429
667
 
430
668
  if (keys.length === 0) {
431
669
  this.#scopedConfig = value;
432
- this.emit('change', keys, value, this.#scopedConfig);
670
+ this.#emitChange(keys, value);
433
671
  return;
434
672
  }
435
673
 
@@ -449,15 +687,19 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
449
687
 
450
688
  obj[keys[keys.length - 1]] = value;
451
689
 
452
- this.emit('change', keys, value, this.#scopedConfig);
690
+ this.#emitChange(keys, value);
453
691
  }
454
692
 
455
- // Test-only: run the change handler directly, since the read's timing relative to its caller
456
- // is the behaviour under test and a chokidar event cannot be observed at that granularity.
457
- // Resolves once the read has landed, which for the root config has already happened.
458
- _handleChangeForTests(): Promise<unknown> {
459
- this.#handleChange();
460
- return Promise.allSettled([...this.#pendingReads]);
693
+ // `#merge` runs inside chokidar's own dispatch on the unlink and env-fallback paths, where a
694
+ // plugin's `change` handler throwing would leave the worker with an uncaught exception on a
695
+ // config event the watcher had just decided to survive. It still reaches consumers as `error`
696
+ // — a listener fault is not a read fault, which is why it must not reach `#handleReadError`.
697
+ #emitChange(keys: string[], value: ConfigValue) {
698
+ try {
699
+ this.emit('change', keys, value, this.#scopedConfig);
700
+ } catch (error) {
701
+ this.#emitError(error);
702
+ }
461
703
  }
462
704
 
463
705
  // Test-only: simulate the underlying chokidar watcher emitting an error.
@@ -467,26 +709,50 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
467
709
  this.#handleError(error);
468
710
  }
469
711
 
470
- // Test-only: whether the watcher has fallen back to polling.
712
+ // Test-only: a real unlink cannot be ordered against a read already in flight — see DESIGN.md,
713
+ // "Root config watchers must read synchronously".
714
+ _simulateUnlinkForTests(path: string): void {
715
+ this.#handleUnlink(path);
716
+ }
717
+
471
718
  get _usingPollingForTests(): boolean {
472
719
  return this.#usingPolling;
473
720
  }
474
721
 
475
- // Test-only: number of times the underlying watcher has been (re)opened.
476
- // Used to assert that a close()-during-fallback race didn't install a
477
- // replacement watcher.
722
+ // Used to assert that a close()-during-fallback race didn't install a replacement watcher.
478
723
  get _openCountForTests(): number {
479
724
  return this.#openCount;
480
725
  }
481
726
 
727
+ // Distinguishes a ladder rung from a watcher event.
728
+ get _readCountForTests(): number {
729
+ return this.#readCount;
730
+ }
731
+
732
+ get _armedForTests(): boolean {
733
+ return this.#armGate.armed;
734
+ }
735
+
736
+ // Test-only: read now, rather than at whatever granularity a chokidar event would arrive.
737
+ _refreshForTests(arming: boolean = false): Promise<unknown> {
738
+ this.#read(true, arming);
739
+ return Promise.allSettled([...this.#pendingReads]);
740
+ }
741
+
482
742
  /**
483
743
  * Closes the underlying file watcher and drains any pending config-file reads.
484
744
  * Emits `close` synchronously, removes all listeners, then returns a Promise that
485
745
  * resolves once the chokidar watcher has fully stopped and all in-flight reads settle.
486
746
  */
487
747
  close(): Promise<this> {
748
+ // Terminal like every other outcome, and `Scope.ready` has no timeout. Before `#closed` and
749
+ // through `#emitReady`, so a listener that throws cannot skip the teardown below it.
750
+ this.#settleUnconfigured();
488
751
  this.#closed = true;
489
- this.#partialRead.cancel();
752
+ this.#readRetry.cancel();
753
+ this.#armGate.cancel();
754
+ if (this.#armAbsence) clearImmediate(this.#armAbsence);
755
+ this.#armAbsence = undefined;
490
756
  const pendingReads = [...this.#pendingReads];
491
757
  const watcherClose = Promise.resolve(this.#watcher.close()).catch(() => {});
492
758