@did-btcr2/cli 0.12.1 → 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.
@@ -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;AAIjE,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;CAC3B,CAAC;AAEF;;;;;;;;;;GAUG;AACH,qBAAa,YAAa,YAAW,aAAa,CAAC,aAAa,EAAE,QAAQ,CAAC;;gBAO7D,OAAO,EAAE,mBAAmB;IAyExC,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;IAa7C,MAAM,CAAC,EAAE,EAAE,aAAa,GAAG,OAAO;IASlC,KAAK,IAAI,IAAI;IAMb,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;CAO/C"}
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.1",
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",
@@ -61,12 +61,12 @@
61
61
  "@scure/base": "^1.2.6",
62
62
  "@web5/dids": "^1.2.0",
63
63
  "commander": "^13.1.0",
64
- "@did-btcr2/api": "^0.11.0",
65
- "@did-btcr2/common": "^9.1.0",
64
+ "@did-btcr2/api": "^0.11.1",
66
65
  "@did-btcr2/cryptosuite": "^8.0.0",
67
- "@did-btcr2/key-manager": "^0.7.0",
66
+ "@did-btcr2/common": "^9.1.0",
68
67
  "@did-btcr2/keypair": "^0.13.1",
69
- "@did-btcr2/method": "^0.40.0"
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.#load();
85
+ this.#loadFromDisk();
70
86
  }
71
87
 
72
- #load(): void {
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.#cache.set(id, {
172
- publicKey : value.publicKey,
173
- ...(value.tags && { tags: value.tags }),
174
- ...(secret && { secret }),
175
- ...(value.secretKey && { decrypted: value.secretKey }),
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
- const existed = this.#cache.delete(id);
182
- if (existed) {
183
- if (this.#active === id) this.#active = undefined;
184
- this.#flush();
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.#cache.clear();
191
- this.#active = undefined;
192
- this.#flush();
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
- if (id !== undefined && !this.#cache.has(id)) {
237
- throw new KeyStoreError(`Cannot set unknown key as active: ${id}.`, 'KEY_NOT_FOUND_ERROR', { keyId: id });
238
- }
239
- this.#active = id;
240
- this.#flush();
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
+ }