@did-btcr2/cli 0.12.2 → 0.12.3
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/dist/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +165 -38
- package/dist/esm/src/keystore/file-key-store.js +81 -21
- package/dist/esm/src/keystore/file-key-store.js.map +1 -1
- package/dist/esm/src/keystore/lock.js +127 -0
- package/dist/esm/src/keystore/lock.js.map +1 -0
- package/dist/types/src/keystore/file-key-store.d.ts +12 -0
- package/dist/types/src/keystore/file-key-store.d.ts.map +1 -1
- package/dist/types/src/keystore/lock.d.ts +26 -0
- package/dist/types/src/keystore/lock.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/keystore/file-key-store.ts +84 -21
- package/src/keystore/lock.ts +143 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock.js","sourceRoot":"","sources":["../../../../src/keystore/lock.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { KeyEntry, KeyIdentifier, KeyValueStore } from '@did-btcr2/key-manager';
|
|
2
2
|
import type { ArgonParams } from './envelope.js';
|
|
3
|
+
import { type LockOptions } from './lock.js';
|
|
3
4
|
/** Current on-disk keystore file format version. */
|
|
4
5
|
export declare const KEYSTORE_VERSION: 1;
|
|
5
6
|
/** Options for constructing a {@link FileKeyStore}. */
|
|
@@ -10,6 +11,8 @@ export type FileKeyStoreOptions = {
|
|
|
10
11
|
getPassphrase: () => string;
|
|
11
12
|
/** argon2id cost parameters used when sealing new secrets. Defaults to {@link DEFAULT_ARGON_PARAMS}. */
|
|
12
13
|
argonParams?: ArgonParams;
|
|
14
|
+
/** Tuning for the cross-process write lock. Defaults documented on {@link LockOptions}. */
|
|
15
|
+
lock?: LockOptions;
|
|
13
16
|
};
|
|
14
17
|
/**
|
|
15
18
|
* A Node-only, file-backed {@link KeyValueStore} that encrypts secret keys at
|
|
@@ -17,6 +20,15 @@ export type FileKeyStoreOptions = {
|
|
|
17
20
|
* in memory at construction and flushing the whole file atomically on every
|
|
18
21
|
* mutation.
|
|
19
22
|
*
|
|
23
|
+
* Every mutation runs under an exclusive cross-process lock and, inside that
|
|
24
|
+
* lock, reloads the file from disk before applying its change and flushing.
|
|
25
|
+
* Caching at construction and flushing the whole file is otherwise a lost-update
|
|
26
|
+
* race: two `btcr2` processes that each load, then each write, would have the
|
|
27
|
+
* second overwrite the first. The lock serializes the writers and the reload
|
|
28
|
+
* merges any change the other made, so concurrent invocations compose instead of
|
|
29
|
+
* clobbering. Reads stay lock-free: an atomic rename means a concurrent reader
|
|
30
|
+
* always sees a complete file, old or new.
|
|
31
|
+
*
|
|
20
32
|
* Secrets are materialized only through {@link FileKeyStore.get}. The
|
|
21
33
|
* {@link FileKeyStore.list} and {@link FileKeyStore.entries} projections omit
|
|
22
34
|
* secret keys and never decrypt, so enumerating the store never triggers a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-key-store.d.ts","sourceRoot":"","sources":["../../../../src/keystore/file-key-store.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAIrF,OAAO,KAAK,EAAE,WAAW,EAAkB,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"file-key-store.d.ts","sourceRoot":"","sources":["../../../../src/keystore/file-key-store.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAIrF,OAAO,KAAK,EAAE,WAAW,EAAkB,MAAM,eAAe,CAAC;AAEjE,OAAO,EAAgB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAG3D,oDAAoD;AACpD,eAAO,MAAM,gBAAgB,EAAG,CAAU,CAAC;AAwB3C,uDAAuD;AACvD,MAAM,MAAM,mBAAmB,GAAG;IAChC,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0FAA0F;IAC1F,aAAa,EAAE,MAAM,MAAM,CAAC;IAC5B,wGAAwG;IACxG,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,2FAA2F;IAC3F,IAAI,CAAC,EAAE,WAAW,CAAC;CACpB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,YAAa,YAAW,aAAa,CAAC,aAAa,EAAE,QAAQ,CAAC;;gBAS7D,OAAO,EAAE,mBAAmB;IAiHxC,GAAG,CAAC,EAAE,EAAE,aAAa,GAAG,QAAQ,GAAG,SAAS;IA0B5C,GAAG,CAAC,EAAE,EAAE,aAAa,GAAG,OAAO;IAI/B,GAAG,CAAC,EAAE,EAAE,aAAa,EAAE,KAAK,EAAE,QAAQ,GAAG,IAAI;IAgB7C,MAAM,CAAC,EAAE,EAAE,aAAa,GAAG,OAAO;IAWlC,KAAK,IAAI,IAAI;IAOb,iFAAiF;IACjF,IAAI,IAAI,KAAK,CAAC,QAAQ,CAAC;IAIvB;;;;;;OAMG;IACH,OAAO,IAAI,KAAK,CAAC,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;IAW3C,KAAK,IAAI,IAAI;IAQb,wEAAwE;IACxE,SAAS,IAAI,MAAM,GAAG,SAAS;IAI/B;;;OAGG;IACH,SAAS,CAAC,EAAE,EAAE,aAAa,GAAG,SAAS,GAAG,IAAI;CAU/C"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Tuning for {@link withFileLock}; all values are milliseconds. */
|
|
2
|
+
export interface LockOptions {
|
|
3
|
+
/** Maximum total time to wait to acquire the lock before failing. Default 10000. */
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
/** A held lock older than this, or whose writer process is gone, is treated as abandoned and broken. Default 30000. */
|
|
6
|
+
staleMs?: number;
|
|
7
|
+
/** Poll interval between acquisition attempts while the lock is held. Default 50. */
|
|
8
|
+
retryMs?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Runs `fn` while holding an exclusive, cross-process advisory lock on
|
|
12
|
+
* `lockPath`, then releases it.
|
|
13
|
+
*
|
|
14
|
+
* The lock is an `O_EXCL` lockfile: creating it fails when another holder
|
|
15
|
+
* exists, which serializes mutators across separate `btcr2` processes. This is
|
|
16
|
+
* the missing half of a safe read-modify-write on the keystore file: an atomic
|
|
17
|
+
* rename keeps the file from tearing, but only mutual exclusion (paired with a
|
|
18
|
+
* reload inside the lock) keeps two concurrent writers from clobbering each
|
|
19
|
+
* other's changes. A lock whose writer has died, or that has aged past
|
|
20
|
+
* `staleMs`, is broken so a crash cannot deadlock future invocations.
|
|
21
|
+
*
|
|
22
|
+
* @throws {KeyStoreError} `KEYSTORE_LOCKED_ERROR` if the lock cannot be acquired
|
|
23
|
+
* within `timeoutMs`, or `KEYSTORE_LOCK_ERROR` on an unexpected filesystem error.
|
|
24
|
+
*/
|
|
25
|
+
export declare function withFileLock<T>(lockPath: string, fn: () => T, options?: LockOptions): T;
|
|
26
|
+
//# sourceMappingURL=lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../../../../src/keystore/lock.ts"],"names":[],"mappings":"AAGA,oEAAoE;AACpE,MAAM,WAAW,WAAW;IAC1B,oFAAoF;IACpF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uHAAuH;IACvH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qFAAqF;IACrF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AA0ED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,OAAO,GAAE,WAAgB,GAAG,CAAC,CA0C3F"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@did-btcr2/cli",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI for interacting with did-btcr2-js, the JavaScript/TypeScript reference implementation of the did:btcr2 method. Exposes various parts of multiple packages in the did-btcr2-js monorepo.",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -62,11 +62,11 @@
|
|
|
62
62
|
"@web5/dids": "^1.2.0",
|
|
63
63
|
"commander": "^13.1.0",
|
|
64
64
|
"@did-btcr2/api": "^0.11.1",
|
|
65
|
-
"@did-btcr2/key-manager": "^0.7.0",
|
|
66
|
-
"@did-btcr2/common": "^9.1.0",
|
|
67
65
|
"@did-btcr2/cryptosuite": "^8.0.0",
|
|
66
|
+
"@did-btcr2/common": "^9.1.0",
|
|
68
67
|
"@did-btcr2/keypair": "^0.13.1",
|
|
69
|
-
"@did-btcr2/method": "^0.40.1"
|
|
68
|
+
"@did-btcr2/method": "^0.40.1",
|
|
69
|
+
"@did-btcr2/key-manager": "^0.7.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@eslint/js": "^9.21.0",
|
|
@@ -6,6 +6,7 @@ import { assertSecurePerms, ensureDir, writeFileAtomic } from './atomic.js';
|
|
|
6
6
|
import { DEFAULT_ARGON_PARAMS, decryptSecret, encryptSecret } from './envelope.js';
|
|
7
7
|
import type { ArgonParams, SecretEnvelope } from './envelope.js';
|
|
8
8
|
import { KeyStoreError } from './error.js';
|
|
9
|
+
import { withFileLock, type LockOptions } from './lock.js';
|
|
9
10
|
import { defaultKeystorePath } from './paths.js';
|
|
10
11
|
|
|
11
12
|
/** Current on-disk keystore file format version. */
|
|
@@ -41,6 +42,8 @@ export type FileKeyStoreOptions = {
|
|
|
41
42
|
getPassphrase: () => string;
|
|
42
43
|
/** argon2id cost parameters used when sealing new secrets. Defaults to {@link DEFAULT_ARGON_PARAMS}. */
|
|
43
44
|
argonParams?: ArgonParams;
|
|
45
|
+
/** Tuning for the cross-process write lock. Defaults documented on {@link LockOptions}. */
|
|
46
|
+
lock?: LockOptions;
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
/**
|
|
@@ -49,6 +52,15 @@ export type FileKeyStoreOptions = {
|
|
|
49
52
|
* in memory at construction and flushing the whole file atomically on every
|
|
50
53
|
* mutation.
|
|
51
54
|
*
|
|
55
|
+
* Every mutation runs under an exclusive cross-process lock and, inside that
|
|
56
|
+
* lock, reloads the file from disk before applying its change and flushing.
|
|
57
|
+
* Caching at construction and flushing the whole file is otherwise a lost-update
|
|
58
|
+
* race: two `btcr2` processes that each load, then each write, would have the
|
|
59
|
+
* second overwrite the first. The lock serializes the writers and the reload
|
|
60
|
+
* merges any change the other made, so concurrent invocations compose instead of
|
|
61
|
+
* clobbering. Reads stay lock-free: an atomic rename means a concurrent reader
|
|
62
|
+
* always sees a complete file, old or new.
|
|
63
|
+
*
|
|
52
64
|
* Secrets are materialized only through {@link FileKeyStore.get}. The
|
|
53
65
|
* {@link FileKeyStore.list} and {@link FileKeyStore.entries} projections omit
|
|
54
66
|
* secret keys and never decrypt, so enumerating the store never triggers a
|
|
@@ -56,6 +68,8 @@ export type FileKeyStoreOptions = {
|
|
|
56
68
|
*/
|
|
57
69
|
export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
|
|
58
70
|
readonly #path: string;
|
|
71
|
+
readonly #lockPath: string;
|
|
72
|
+
readonly #lockOptions: LockOptions;
|
|
59
73
|
readonly #getPassphrase: () => string;
|
|
60
74
|
readonly #argonParams: ArgonParams;
|
|
61
75
|
readonly #cache: Map<KeyIdentifier, CacheEntry> = new Map();
|
|
@@ -63,13 +77,15 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
|
|
|
63
77
|
|
|
64
78
|
constructor(options: FileKeyStoreOptions) {
|
|
65
79
|
this.#path = options.path ?? defaultKeystorePath();
|
|
80
|
+
this.#lockPath = `${this.#path}.lock`;
|
|
81
|
+
this.#lockOptions = options.lock ?? {};
|
|
66
82
|
this.#getPassphrase = options.getPassphrase;
|
|
67
83
|
this.#argonParams = options.argonParams ?? DEFAULT_ARGON_PARAMS;
|
|
68
84
|
ensureDir(dirname(this.#path), 0o700);
|
|
69
|
-
this.#
|
|
85
|
+
this.#loadFromDisk();
|
|
70
86
|
}
|
|
71
87
|
|
|
72
|
-
#
|
|
88
|
+
#loadFromDisk(): void {
|
|
73
89
|
if (!existsSync(this.#path)) return;
|
|
74
90
|
assertSecurePerms(this.#path);
|
|
75
91
|
let parsed: KeystoreFile;
|
|
@@ -134,6 +150,44 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
|
|
|
134
150
|
writeFileAtomic(this.#path, `${JSON.stringify(file, null, 2)}\n`, 0o600);
|
|
135
151
|
}
|
|
136
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Re-reads the file into the cache, discarding the prior in-memory view so a
|
|
155
|
+
* mutation applies on top of whatever other processes have written. Secrets
|
|
156
|
+
* already decrypted this session are carried over for entries whose sealed
|
|
157
|
+
* envelope is byte-identical on disk, so a mid-session write does not force a
|
|
158
|
+
* re-prompt for keys it did not touch.
|
|
159
|
+
*/
|
|
160
|
+
#reload(): void {
|
|
161
|
+
const carried = new Map<KeyIdentifier, { secret: SecretEnvelope; decrypted: Uint8Array }>();
|
|
162
|
+
for (const [ id, entry ] of this.#cache) {
|
|
163
|
+
if (entry.secret && entry.decrypted) carried.set(id, { secret: entry.secret, decrypted: entry.decrypted });
|
|
164
|
+
}
|
|
165
|
+
this.#cache.clear();
|
|
166
|
+
this.#active = undefined;
|
|
167
|
+
this.#loadFromDisk();
|
|
168
|
+
for (const [ id, prior ] of carried) {
|
|
169
|
+
const entry = this.#cache.get(id);
|
|
170
|
+
if (entry?.secret && JSON.stringify(entry.secret) === JSON.stringify(prior.secret)) {
|
|
171
|
+
entry.decrypted = prior.decrypted;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Runs a cache mutation under the exclusive write lock, reloading the file
|
|
178
|
+
* first so the change merges with any concurrent writer's change rather than
|
|
179
|
+
* overwriting it, then flushing the result atomically. Callers must do any
|
|
180
|
+
* expensive work (such as sealing a secret with argon2id) before calling this,
|
|
181
|
+
* so the locked critical section stays short.
|
|
182
|
+
*/
|
|
183
|
+
#mutate(apply: () => void): void {
|
|
184
|
+
withFileLock(this.#lockPath, () => {
|
|
185
|
+
this.#reload();
|
|
186
|
+
apply();
|
|
187
|
+
this.#flush();
|
|
188
|
+
}, this.#lockOptions);
|
|
189
|
+
}
|
|
190
|
+
|
|
137
191
|
get(id: KeyIdentifier): KeyEntry | undefined {
|
|
138
192
|
const entry = this.#cache.get(id);
|
|
139
193
|
if (!entry) return undefined;
|
|
@@ -165,31 +219,37 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
|
|
|
165
219
|
}
|
|
166
220
|
|
|
167
221
|
set(id: KeyIdentifier, value: KeyEntry): void {
|
|
222
|
+
// Seal the secret before taking the lock: argon2id is deliberately slow and
|
|
223
|
+
// must not extend the critical section that blocks other processes.
|
|
168
224
|
const secret = value.secretKey
|
|
169
225
|
? encryptSecret(value.secretKey, this.#getPassphrase(), this.#argonParams)
|
|
170
226
|
: undefined;
|
|
171
|
-
this.#
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
227
|
+
this.#mutate(() => {
|
|
228
|
+
this.#cache.set(id, {
|
|
229
|
+
publicKey : value.publicKey,
|
|
230
|
+
...(value.tags && { tags: value.tags }),
|
|
231
|
+
...(secret && { secret }),
|
|
232
|
+
...(value.secretKey && { decrypted: value.secretKey }),
|
|
233
|
+
});
|
|
176
234
|
});
|
|
177
|
-
this.#flush();
|
|
178
235
|
}
|
|
179
236
|
|
|
180
237
|
delete(id: KeyIdentifier): boolean {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
238
|
+
// `existed` reflects the freshly-reloaded state inside the lock, so a key a
|
|
239
|
+
// concurrent process already removed reads as absent rather than resurrected.
|
|
240
|
+
let existed = false;
|
|
241
|
+
this.#mutate(() => {
|
|
242
|
+
existed = this.#cache.delete(id);
|
|
243
|
+
if (existed && this.#active === id) this.#active = undefined;
|
|
244
|
+
});
|
|
186
245
|
return existed;
|
|
187
246
|
}
|
|
188
247
|
|
|
189
248
|
clear(): void {
|
|
190
|
-
this.#
|
|
191
|
-
|
|
192
|
-
|
|
249
|
+
this.#mutate(() => {
|
|
250
|
+
this.#cache.clear();
|
|
251
|
+
this.#active = undefined;
|
|
252
|
+
});
|
|
193
253
|
}
|
|
194
254
|
|
|
195
255
|
/** All stored values with secret keys omitted. Never decrypts, never prompts. */
|
|
@@ -233,10 +293,13 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
|
|
|
233
293
|
* clears it. Throws if the identifier is not a known key.
|
|
234
294
|
*/
|
|
235
295
|
setActive(id: KeyIdentifier | undefined): void {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
296
|
+
this.#mutate(() => {
|
|
297
|
+
// The existence check runs against the reloaded state, so a key another
|
|
298
|
+
// process added in the meantime is a valid active target.
|
|
299
|
+
if (id !== undefined && !this.#cache.has(id)) {
|
|
300
|
+
throw new KeyStoreError(`Cannot set unknown key as active: ${id}.`, 'KEY_NOT_FOUND_ERROR', { keyId: id });
|
|
301
|
+
}
|
|
302
|
+
this.#active = id;
|
|
303
|
+
});
|
|
241
304
|
}
|
|
242
305
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, rmSync, statSync, writeSync } from 'node:fs';
|
|
2
|
+
import { KeyStoreError } from './error.js';
|
|
3
|
+
|
|
4
|
+
/** Tuning for {@link withFileLock}; all values are milliseconds. */
|
|
5
|
+
export interface LockOptions {
|
|
6
|
+
/** Maximum total time to wait to acquire the lock before failing. Default 10000. */
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
/** A held lock older than this, or whose writer process is gone, is treated as abandoned and broken. Default 30000. */
|
|
9
|
+
staleMs?: number;
|
|
10
|
+
/** Poll interval between acquisition attempts while the lock is held. Default 50. */
|
|
11
|
+
retryMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
15
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
16
|
+
const DEFAULT_RETRY_MS = 50;
|
|
17
|
+
|
|
18
|
+
// A per-process counter so a lock token is unambiguous even when one process
|
|
19
|
+
// runs several stores over the same path (as the tests do): the pid alone would
|
|
20
|
+
// collide, the pid plus counter never does.
|
|
21
|
+
let tokenCounter = 0;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sleeps synchronously for `ms` without spinning the CPU. The keystore store is
|
|
25
|
+
* a synchronous interface, so the wait must block the thread rather than yield a
|
|
26
|
+
* promise. `Atomics.wait` on a private buffer no other thread can notify always
|
|
27
|
+
* runs the full duration. Node-only, which the keystore already is.
|
|
28
|
+
*/
|
|
29
|
+
function sleepSync(ms: number): void {
|
|
30
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reports whether a process is still running. `process.kill(pid, 0)` sends no
|
|
35
|
+
* signal but performs the existence/permission check: ESRCH means the process
|
|
36
|
+
* is gone, EPERM means it exists under another user (still alive).
|
|
37
|
+
*/
|
|
38
|
+
function isProcessAlive(pid: number): boolean {
|
|
39
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return (error as { code?: string }).code === 'EPERM';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Removes the lock if its writer process is gone or it has aged past `staleMs`,
|
|
50
|
+
* so a process that crashed mid-mutation cannot wedge the keystore permanently.
|
|
51
|
+
* Returns true when the lock was broken or had already vanished (caller should
|
|
52
|
+
* retry the create immediately), false when a live, fresh holder still owns it.
|
|
53
|
+
*/
|
|
54
|
+
function breakIfStale(lockPath: string, staleMs: number): boolean {
|
|
55
|
+
let ageMs: number;
|
|
56
|
+
let pid: number;
|
|
57
|
+
try {
|
|
58
|
+
const stat = statSync(lockPath);
|
|
59
|
+
ageMs = Date.now() - stat.mtimeMs;
|
|
60
|
+
pid = Number.parseInt(readFileSync(lockPath, 'utf-8').split('.')[0] ?? '', 10);
|
|
61
|
+
} catch {
|
|
62
|
+
// The lock disappeared between our failed create and this inspection; the
|
|
63
|
+
// caller can race for it again straight away.
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (ageMs > staleMs || !isProcessAlive(pid)) {
|
|
67
|
+
try {
|
|
68
|
+
rmSync(lockPath, { force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
// Another waiter broke it first; either way it is gone, so retry.
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Releases the lock only while it still holds our token, never one broken from us as stale. */
|
|
78
|
+
function releaseIfOwner(lockPath: string, token: string): void {
|
|
79
|
+
try {
|
|
80
|
+
if (readFileSync(lockPath, 'utf-8') === token) rmSync(lockPath, { force: true });
|
|
81
|
+
} catch {
|
|
82
|
+
// Already removed (broken as stale, or never created); nothing to release.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Runs `fn` while holding an exclusive, cross-process advisory lock on
|
|
88
|
+
* `lockPath`, then releases it.
|
|
89
|
+
*
|
|
90
|
+
* The lock is an `O_EXCL` lockfile: creating it fails when another holder
|
|
91
|
+
* exists, which serializes mutators across separate `btcr2` processes. This is
|
|
92
|
+
* the missing half of a safe read-modify-write on the keystore file: an atomic
|
|
93
|
+
* rename keeps the file from tearing, but only mutual exclusion (paired with a
|
|
94
|
+
* reload inside the lock) keeps two concurrent writers from clobbering each
|
|
95
|
+
* other's changes. A lock whose writer has died, or that has aged past
|
|
96
|
+
* `staleMs`, is broken so a crash cannot deadlock future invocations.
|
|
97
|
+
*
|
|
98
|
+
* @throws {KeyStoreError} `KEYSTORE_LOCKED_ERROR` if the lock cannot be acquired
|
|
99
|
+
* within `timeoutMs`, or `KEYSTORE_LOCK_ERROR` on an unexpected filesystem error.
|
|
100
|
+
*/
|
|
101
|
+
export function withFileLock<T>(lockPath: string, fn: () => T, options: LockOptions = {}): T {
|
|
102
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
103
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
104
|
+
const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
|
|
105
|
+
const token = `${process.pid}.${tokenCounter++}`;
|
|
106
|
+
const deadline = Date.now() + timeoutMs;
|
|
107
|
+
|
|
108
|
+
for (;;) {
|
|
109
|
+
try {
|
|
110
|
+
const fd = openSync(lockPath, 'wx', 0o600);
|
|
111
|
+
try {
|
|
112
|
+
writeSync(fd, token);
|
|
113
|
+
} finally {
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if ((error as { code?: string }).code !== 'EEXIST') {
|
|
119
|
+
throw new KeyStoreError(
|
|
120
|
+
`Failed to acquire keystore lock at ${lockPath}.`,
|
|
121
|
+
'KEYSTORE_LOCK_ERROR',
|
|
122
|
+
{ lockPath, cause: error instanceof Error ? error.message : String(error) },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (breakIfStale(lockPath, staleMs)) continue;
|
|
126
|
+
if (Date.now() >= deadline) {
|
|
127
|
+
throw new KeyStoreError(
|
|
128
|
+
`Timed out after ${timeoutMs}ms waiting for the keystore lock at ${lockPath}. `
|
|
129
|
+
+ 'Another btcr2 process may be writing; retry, or remove the lock file if no other process is running.',
|
|
130
|
+
'KEYSTORE_LOCKED_ERROR',
|
|
131
|
+
{ lockPath, timeoutMs },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
sleepSync(retryMs);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
return fn();
|
|
140
|
+
} finally {
|
|
141
|
+
releaseIfOwner(lockPath, token);
|
|
142
|
+
}
|
|
143
|
+
}
|