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