@nomicfoundation/hardhat-utils 3.0.6 → 4.0.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 (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/src/errors/synchronization.d.ts +29 -0
  3. package/dist/src/errors/synchronization.d.ts.map +1 -0
  4. package/dist/src/errors/synchronization.js +48 -0
  5. package/dist/src/errors/synchronization.js.map +1 -0
  6. package/dist/src/format.d.ts +7 -41
  7. package/dist/src/format.d.ts.map +1 -1
  8. package/dist/src/format.js +2 -72
  9. package/dist/src/format.js.map +1 -1
  10. package/dist/src/global-dir.d.ts +16 -0
  11. package/dist/src/global-dir.d.ts.map +1 -1
  12. package/dist/src/global-dir.js +27 -0
  13. package/dist/src/global-dir.js.map +1 -1
  14. package/dist/src/internal/format.d.ts +3 -3
  15. package/dist/src/internal/format.d.ts.map +1 -1
  16. package/dist/src/internal/format.js.map +1 -1
  17. package/dist/src/internal/request.d.ts.map +1 -1
  18. package/dist/src/internal/request.js +2 -1
  19. package/dist/src/internal/request.js.map +1 -1
  20. package/dist/src/lang.d.ts +1 -0
  21. package/dist/src/lang.d.ts.map +1 -1
  22. package/dist/src/lang.js +13 -0
  23. package/dist/src/lang.js.map +1 -1
  24. package/dist/src/request.d.ts.map +1 -1
  25. package/dist/src/request.js +24 -6
  26. package/dist/src/request.js.map +1 -1
  27. package/dist/src/synchronization.d.ts +99 -1
  28. package/dist/src/synchronization.d.ts.map +1 -1
  29. package/dist/src/synchronization.js +407 -89
  30. package/dist/src/synchronization.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/errors/synchronization.ts +75 -0
  33. package/src/format.ts +11 -94
  34. package/src/global-dir.ts +31 -0
  35. package/src/internal/format.ts +3 -3
  36. package/src/internal/request.ts +2 -1
  37. package/src/lang.ts +15 -0
  38. package/src/request.ts +29 -6
  39. package/src/synchronization.ts +504 -95
@@ -1,164 +1,573 @@
1
- // Logic explanation: the fs.writeFile function, when used with the wx+ flag, performs an atomic operation to create a file.
2
- // If multiple processes try to create the same file simultaneously, only one will succeed.
3
- // This logic can be utilized to implement a mutex.
4
- // ATTENTION: in the current implementation, there's still a risk of two processes running simultaneously.
5
- // For example, if processA has locked the mutex and is running, processB will wait.
6
- // During this wait, processB continuously checks the elapsed time since the mutex lock file was created.
7
- // If an excessive amount of time has passed, processB will assume ownership of the mutex to prevent stale locks, even if processA is still running.
8
- // As a result, two processes will be running simultaneously in what is theoretically a mutex-locked section.
9
-
10
- import fs from "node:fs";
11
- import os from "node:os";
12
- import path from "node:path";
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
13
5
 
14
6
  import debug from "debug";
15
7
 
16
- import { ensureNodeErrnoExceptionError } from "./error.js";
17
- import { FileSystemAccessError } from "./errors/fs.js";
18
- import { readUtf8File } from "./fs.js";
8
+ import { ensureError, ensureNodeErrnoExceptionError } from "./error.js";
9
+ import {
10
+ BaseMultiProcessMutexError,
11
+ IncompatibleHostnameMultiProcessMutexError,
12
+ IncompatiblePlatformMultiProcessMutexError,
13
+ IncompatibleUidMultiProcessMutexError,
14
+ InvalidMultiProcessMutexPathError,
15
+ MultiProcessMutexError,
16
+ MultiProcessMutexTimeoutError,
17
+ StaleMultiProcessMutexError,
18
+ } from "./errors/synchronization.js";
19
+ import { ensureDir } from "./fs.js";
19
20
  import { sleep } from "./lang.js";
20
21
 
22
+ export {
23
+ IncompatibleHostnameMultiProcessMutexError,
24
+ IncompatibleMultiProcessMutexError,
25
+ IncompatiblePlatformMultiProcessMutexError,
26
+ IncompatibleUidMultiProcessMutexError,
27
+ InvalidMultiProcessMutexPathError,
28
+ MultiProcessMutexError,
29
+ MultiProcessMutexTimeoutError,
30
+ StaleMultiProcessMutexError,
31
+ } from "./errors/synchronization.js";
32
+
21
33
  const log = debug("hardhat:util:multi-process-mutex");
22
- const DEFAULT_MAX_MUTEX_LIFESPAN_IN_MS = 60000;
23
- const MUTEX_LOOP_WAITING_TIME_IN_MS = 100;
24
34
 
35
+ const PROCESS_SESSION_ID = randomUUID();
36
+ const DEFAULT_TIMEOUT_MS = 60_000;
37
+ const DEFAULT_INITIAL_POLL_INTERVAL_MS = 5;
38
+ const MAX_POLL_INTERVAL_MS = 200;
39
+
40
+ /**
41
+ * Error codes indicating hard links are definitively unsupported on the
42
+ * target filesystem. These cause immediate failure rather than retries.
43
+ */
44
+ const HARD_LINK_UNSUPPORTED_CODES = new Set(["EOPNOTSUPP", "ENOTSUP", "EXDEV"]);
45
+
46
+ interface LockMetadata {
47
+ pid: number;
48
+ hostname: string;
49
+ createdAt: number;
50
+ uid?: number;
51
+ platform: string;
52
+ sessionId: string;
53
+ }
54
+
55
+ type StalenessResult =
56
+ | { isStale: true; metadata: LockMetadata | undefined }
57
+ | { isStale: false };
58
+
59
+ type AcquireResult =
60
+ | { acquired: true }
61
+ | { acquired: false; reclaimedStaleLock: boolean };
62
+
63
+ /**
64
+ * A class that implements an inter-process mutex.
65
+ *
66
+ * This Mutex is implemented using hard-link-based atomic file creation. A
67
+ * temporary file containing JSON metadata (PID, hostname, platform, uid,
68
+ * session ID, and creation timestamp) is written first, then hard-linked to
69
+ * the lock path via `fs.linkSync`. `linkSync` fails atomically with `EEXIST`
70
+ * if the lock already exists, ensuring only one process can hold the lock at
71
+ * a time.
72
+ *
73
+ * Staleness is determined by PID liveness only — timestamps are stored for
74
+ * debugging purposes but are never used to determine staleness. This avoids the
75
+ * clock-skew and long-running-task problems that time-based staleness detection
76
+ * has (where a second process can break into a lock that's still legitimately
77
+ * held).
78
+ *
79
+ * Incompatible locks — those created by a different hostname, platform, or
80
+ * uid — are rejected immediately with specific subclasses of
81
+ * `IncompatibleMultiProcessMutexError`
82
+ * (`IncompatibleHostnameMultiProcessMutexError`,
83
+ * `IncompatiblePlatformMultiProcessMutexError`, or
84
+ * `IncompatibleUidMultiProcessMutexError`) because their PID liveness cannot
85
+ * be verified or their lock file cannot be removed. These must be removed
86
+ * manually.
87
+ *
88
+ * When the lock is held by a live process, the caller polls with exponential
89
+ * backoff (default: 5ms → 10ms → ... → 160ms → 200ms cap) until the lock is
90
+ * released or a timeout (default: 60s) is reached.
91
+ *
92
+ * If the filesystem does not support hard links (e.g., certain network
93
+ * filesystems), acquisition fails fast with a `MultiProcessMutexError` rather
94
+ * than degrading into timeout-based retries.
95
+ *
96
+ * ## Performance characteristics
97
+ *
98
+ * - **Uncontended acquisition:** One temp file write + one `linkSync` — takes
99
+ * less than 1ms on most systems.
100
+ * - **Stale lock recovery:** One `readFileSync` to read metadata, one
101
+ * `process.kill(pid, 0)` liveness check, and one `unlinkSync` to remove the
102
+ * stale lock file before retrying acquisition. The retry is immediate (no
103
+ * sleep), so recovery adds sub-millisecond overhead.
104
+ * - **Contended (live holder):** Polls with exponential backoff starting at
105
+ * 5ms and doubling each iteration until capped at 200ms. Worst-case latency
106
+ * after the lock is released is up to `MAX_POLL_INTERVAL_MS` (200ms).
107
+ * - **Release:** A single `unlinkSync` call.
108
+ *
109
+ * ## Limitations
110
+ *
111
+ * - **Polling-based:** There is no filesystem notification; callers discover
112
+ * that the lock is free only on the next poll, so there can be up to 200ms
113
+ * of wasted wait time after the lock is released.
114
+ * - **Not reentrant:** The same process (or even the same `MultiProcessMutex`
115
+ * instance) calling `use()` while already holding the lock will deadlock
116
+ * until the timeout fires.
117
+ * - **Single-host, single-user only:** Encountering a lock from a different
118
+ * hostname throws `IncompatibleHostnameMultiProcessMutexError`, a different
119
+ * platform throws `IncompatiblePlatformMultiProcessMutexError`, and a
120
+ * different uid throws `IncompatibleUidMultiProcessMutexError`. All extend
121
+ * `IncompatibleMultiProcessMutexError`. This means the lock is not safe to
122
+ * use on shared/networked filesystems (e.g., NFS) where multiple hosts or
123
+ * users may access the same path.
124
+ * - **Requires hard-link support:** The underlying filesystem must support
125
+ * `linkSync`. If hard links are unsupported, acquisition fails immediately
126
+ * with `MultiProcessMutexError`.
127
+ * - **PID recycling:** If a process dies and the OS reassigns its PID to a new
128
+ * unrelated process before the stale check runs, the lock is incorrectly
129
+ * considered live. This is extremely unlikely in practice due to the large
130
+ * PID space on modern systems.
131
+ * - **No fairness guarantee:** Multiple waiters polling concurrently have no
132
+ * guaranteed ordering — whichever one succeeds at `linkSync` first after the
133
+ * lock is released wins.
134
+ */
25
135
  export class MultiProcessMutex {
26
- readonly #mutexFilePath: string;
27
- readonly #mutexLifespanInMs: number;
136
+ readonly #lockFilePath: string;
137
+ readonly #timeout: number;
138
+ readonly #initialPollInterval: number;
28
139
 
29
- constructor(mutexName: string, maxMutexLifespanInMs?: number) {
30
- log(`Creating mutex with name '${mutexName}'`);
140
+ /**
141
+ * Creates an inter-process mutex given an absolute path.
142
+ *
143
+ * @param absolutePathToLock The absolute path of the mutex.
144
+ * @param timeout The max amount of time to spend trying to acquire the lock
145
+ * in milliseconds. Defaults to 60000.
146
+ * @param initialPollInterval The initial poll interval in milliseconds.
147
+ * Defaults to 5.
148
+ */
149
+ constructor(
150
+ absolutePathToLock: string,
151
+ timeout?: number,
152
+ initialPollInterval?: number,
153
+ ) {
154
+ if (!path.isAbsolute(absolutePathToLock)) {
155
+ throw new InvalidMultiProcessMutexPathError(absolutePathToLock);
156
+ }
157
+ this.#lockFilePath = absolutePathToLock;
31
158
 
32
- this.#mutexFilePath = path.join(os.tmpdir(), `${mutexName}.txt`);
33
- this.#mutexLifespanInMs =
34
- maxMutexLifespanInMs ?? DEFAULT_MAX_MUTEX_LIFESPAN_IN_MS;
159
+ this.#timeout = timeout ?? DEFAULT_TIMEOUT_MS;
160
+ this.#initialPollInterval =
161
+ initialPollInterval ?? DEFAULT_INITIAL_POLL_INTERVAL_MS;
35
162
  }
36
163
 
164
+ /**
165
+ * Runs the function f while holding the mutex, returning its result.
166
+ *
167
+ * @param f The function to run.
168
+ * @returns The result of the function.
169
+ */
37
170
  public async use<T>(f: () => Promise<T>): Promise<T> {
38
- log(`Starting mutex process with mutex file '${this.#mutexFilePath}'`);
171
+ const release = await this.acquire();
172
+
173
+ try {
174
+ return await f();
175
+ } finally {
176
+ await release();
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Acquires the mutex, returning an async function to release it.
182
+ * The function MUST be called after using the mutex.
183
+ *
184
+ * If this function throws, no cleanup is necessary — the lock was never
185
+ * acquired.
186
+ *
187
+ * @returns The mutex's release function.
188
+ */
189
+ public async acquire(): Promise<() => Promise<void>> {
190
+ log(`Starting mutex process with lock file '${this.#lockFilePath}'`);
191
+
192
+ try {
193
+ await this.#acquireLock();
194
+ } catch (e) {
195
+ ensureError(e);
196
+
197
+ if (e instanceof BaseMultiProcessMutexError) {
198
+ throw e;
199
+ }
200
+
201
+ throw new MultiProcessMutexError(this.#lockFilePath, e);
202
+ }
203
+
204
+ let released = false;
205
+
206
+ return async () => {
207
+ if (released) {
208
+ return;
209
+ }
210
+
211
+ this.#releaseLock();
212
+ released = true;
213
+ };
214
+ }
215
+
216
+ async #acquireLock(): Promise<void> {
217
+ const startTime = Date.now();
218
+ let pollInterval = this.#initialPollInterval;
219
+
220
+ await ensureDir(path.dirname(this.#lockFilePath));
39
221
 
40
222
  while (true) {
41
- if (await this.#tryToAcquireMutex()) {
42
- // Mutex has been acquired
43
- return this.#executeFunctionAndReleaseMutex(f);
223
+ const result = this.#tryAcquire();
224
+
225
+ if (result.acquired) {
226
+ return;
44
227
  }
45
228
 
46
- // Mutex not acquired
47
- if (this.#isMutexFileTooOld()) {
48
- // If the mutex file is too old, it likely indicates a stale lock, so the file should be removed
49
- log(
50
- `Current mutex file is too old, removing it at path '${this.#mutexFilePath}'`,
229
+ // Check timeout
230
+ const elapsed = Date.now() - startTime;
231
+ if (elapsed >= this.#timeout) {
232
+ throw new MultiProcessMutexTimeoutError(
233
+ this.#lockFilePath,
234
+ this.#timeout,
51
235
  );
236
+ }
52
237
 
53
- this.#deleteMutexFile();
54
- } else if (await this.#isMutexProcessOwnerDead()) {
55
- log(
56
- `The process owning the mutex file no longer exists. Removing mutex file at '${this.#mutexFilePath}'.`,
57
- );
238
+ // Skip sleep after reclaiming a stale lock — retry immediately
239
+ if (result.reclaimedStaleLock) {
240
+ continue;
241
+ }
242
+
243
+ // Wait with exponential backoff
244
+ log(`Lock at ${this.#lockFilePath} is busy, waiting ${pollInterval}ms`);
245
+ await sleep(pollInterval / 1000);
246
+
247
+ // Exponential backoff, capped
248
+ pollInterval = Math.min(pollInterval * 2, MAX_POLL_INTERVAL_MS);
249
+ }
250
+ }
58
251
 
59
- this.#deleteMutexFile();
60
- } else {
61
- // wait
62
- await sleep(MUTEX_LOOP_WAITING_TIME_IN_MS / 1000);
252
+ #releaseLock(): void {
253
+ try {
254
+ fs.unlinkSync(this.#lockFilePath);
255
+ log(`Released lock at ${this.#lockFilePath}`);
256
+ } catch (e) {
257
+ ensureNodeErrnoExceptionError(e);
258
+ if (e.code === "ENOENT") {
259
+ log(`Lock at ${this.#lockFilePath} already removed`);
260
+ return;
63
261
  }
262
+ throw new MultiProcessMutexError(this.#lockFilePath, e);
64
263
  }
65
264
  }
66
265
 
67
- async #tryToAcquireMutex() {
266
+ #tryAcquire(): AcquireResult {
267
+ const lockPath = this.#lockFilePath;
268
+
269
+ // Fast path: if the lock file already exists, check staleness directly
270
+ // without creating temp files. This is both an optimization for the
271
+ // common contended case and is required for correct behavior when the
272
+ // parent directory is read-only (stale locks can still be detected via
273
+ // readFileSync even when file creation in the directory is blocked).
274
+ //
275
+ // Note: handleExistingLock() must be called outside the try/catch so
276
+ // that errors like StaleMultiProcessMutexError propagate correctly.
277
+ let lockExists = false;
68
278
  try {
69
- // Create a file only if it does not exist
70
- fs.writeFileSync(this.#mutexFilePath, process.pid.toString(), {
71
- flag: "wx+",
72
- });
279
+ fs.accessSync(lockPath, fs.constants.F_OK);
280
+ lockExists = true;
281
+ } catch {
282
+ // Lock doesn't exist (or can't be checked) — proceed to acquire
283
+ }
73
284
 
74
- return true;
285
+ if (lockExists) {
286
+ return this.#handleExistingLock();
287
+ }
288
+
289
+ // Lock doesn't appear to exist — try to acquire via temp file + hard link
290
+ const metadata = this.#buildMetadata();
291
+ const contents = JSON.stringify(metadata, null, 2);
292
+
293
+ const randomSuffix = Math.random().toString(16).slice(2);
294
+ const tempPath = `${lockPath}.tmp-${process.pid}-${PROCESS_SESSION_ID}-${Date.now()}-${randomSuffix}`;
295
+
296
+ let tempFd: number | undefined;
297
+ try {
298
+ // Create temp file with exclusive flag to prevent collisions
299
+ tempFd = fs.openSync(tempPath, "wx");
300
+ fs.writeFileSync(tempFd, contents, "utf8");
301
+ fs.closeSync(tempFd);
302
+ tempFd = undefined;
303
+
304
+ // Attempt atomic hard link to the lock path
305
+ fs.linkSync(tempPath, lockPath);
306
+
307
+ log(`Acquired lock at ${lockPath}`);
308
+
309
+ // Best-effort cleanup of temp files left by dead processes.
310
+ // We hold the lock, so only one process runs this at a time.
311
+ this.#cleanupDeadProcessTempFiles();
312
+
313
+ return { acquired: true };
75
314
  } catch (e) {
76
315
  ensureNodeErrnoExceptionError(e);
77
316
 
78
317
  if (e.code === "EEXIST") {
79
- // File already exists, so the mutex is already acquired
80
- return false;
318
+ // Lock was created between our accessSync and linkSync
319
+ return this.#handleExistingLock();
320
+ }
321
+
322
+ if (e.code === "ENOENT") {
323
+ // Parent directory doesn't exist. Create it and retry.
324
+ const parentDir = path.dirname(lockPath);
325
+ log(`Parent directory ${parentDir} does not exist, creating it`);
326
+ fs.mkdirSync(parentDir, { recursive: true });
327
+ return { acquired: false, reclaimedStaleLock: false };
328
+ }
329
+
330
+ // Hard links definitively unsupported — fail fast
331
+ if (HARD_LINK_UNSUPPORTED_CODES.has(e.code ?? "")) {
332
+ throw new MultiProcessMutexError(lockPath, e);
333
+ }
334
+
335
+ // We retry on permission errors, as this is a common transient failure
336
+ // on Windows.
337
+ if (e.code === "EPERM" || e.code === "EACCES") {
338
+ log("Failed to acquire lock, retrying due to permission error");
339
+ return { acquired: false, reclaimedStaleLock: false };
340
+ }
341
+
342
+ // Any other error (ENAMETOOLONG, ENOSPC, etc.)
343
+ throw new MultiProcessMutexError(lockPath, e);
344
+ } finally {
345
+ // Close fd if still open (write or close failed)
346
+ if (tempFd !== undefined) {
347
+ try {
348
+ fs.closeSync(tempFd);
349
+ } catch {
350
+ // Best effort
351
+ }
81
352
  }
82
353
 
83
- throw new FileSystemAccessError(e.message, e);
354
+ // Always clean up the temp file
355
+ try {
356
+ fs.unlinkSync(tempPath);
357
+ } catch {
358
+ // Best effort — file may not exist if openSync failed
359
+ }
84
360
  }
85
361
  }
86
362
 
87
- async #executeFunctionAndReleaseMutex<T>(f: () => Promise<T>): Promise<T> {
88
- log(`Mutex acquired at path '${this.#mutexFilePath}'`);
363
+ #handleExistingLock(): AcquireResult {
364
+ const staleness = this.#checkStaleness();
89
365
 
90
- try {
91
- return await f();
92
- } finally {
93
- // Release the mutex
94
- // Note: if a process dies, its `finally` block never executes, and the process hangs indefinitely since no response is received.
95
- // To handle this, we use the function `isMutexProcessOwnerDead`.
96
- log(`Mutex released at path '${this.#mutexFilePath}'`);
97
- this.#deleteMutexFile();
98
- log(`Mutex released at path '${this.#mutexFilePath}'`);
366
+ if (staleness.isStale) {
367
+ const reclaimed = this.#tryUnlockingStaleLock(staleness.metadata);
368
+ return { acquired: false, reclaimedStaleLock: reclaimed };
369
+ }
370
+
371
+ return { acquired: false, reclaimedStaleLock: false };
372
+ }
373
+
374
+ #checkStaleness(): StalenessResult {
375
+ const lockPath = this.#lockFilePath;
376
+ const metadata = this.#readMetadata();
377
+
378
+ if (metadata === undefined) {
379
+ log(
380
+ `Lock at ${lockPath} has missing/corrupt metadata, treating as stale`,
381
+ );
382
+ return { isStale: true, metadata: undefined };
383
+ }
384
+
385
+ // Different hostname — can't verify PID remotely
386
+ if (metadata.hostname !== os.hostname()) {
387
+ throw new IncompatibleHostnameMultiProcessMutexError(
388
+ lockPath,
389
+ metadata.hostname,
390
+ os.hostname(),
391
+ );
392
+ }
393
+
394
+ // Different platform — can't verify PID across platforms
395
+ if (metadata.platform !== process.platform) {
396
+ throw new IncompatiblePlatformMultiProcessMutexError(
397
+ lockPath,
398
+ metadata.platform,
399
+ process.platform,
400
+ );
401
+ }
402
+
403
+ // Different uid — can't remove a lock owned by another user
404
+ const currentUid = process.getuid?.();
405
+ if (
406
+ metadata.uid !== undefined &&
407
+ currentUid !== undefined &&
408
+ metadata.uid !== currentUid
409
+ ) {
410
+ throw new IncompatibleUidMultiProcessMutexError(
411
+ lockPath,
412
+ metadata.uid,
413
+ currentUid,
414
+ );
415
+ }
416
+
417
+ // PID liveness check
418
+ if (!this.#isProcessAlive(metadata.pid)) {
419
+ log(`Lock at ${lockPath} owned by dead process PID=${metadata.pid}`);
420
+ return { isStale: true, metadata };
99
421
  }
422
+
423
+ // Process is alive, lock is not stale
424
+ return { isStale: false };
100
425
  }
101
426
 
102
- #isMutexFileTooOld(): boolean {
103
- let fileStat;
427
+ #tryUnlockingStaleLock(metadata: LockMetadata | undefined): boolean {
428
+ const lockPath = this.#lockFilePath;
429
+
104
430
  try {
105
- fileStat = fs.statSync(this.#mutexFilePath);
431
+ fs.unlinkSync(lockPath);
432
+ log(`Removed stale lock at ${lockPath}`);
106
433
  } catch (e) {
107
434
  ensureNodeErrnoExceptionError(e);
108
435
 
109
436
  if (e.code === "ENOENT") {
110
- // The file might have been deleted by another process while this function was trying to access it.
111
- return false;
437
+ // Already removed by another process safe to retry acquisition
438
+ log(`Stale lock at ${lockPath} already removed by another process`);
439
+ return true;
440
+ }
441
+
442
+ if (e.code === "EACCES" || e.code === "EPERM" || e.code === "EBUSY") {
443
+ throw new StaleMultiProcessMutexError(lockPath, metadata?.uid, e);
112
444
  }
113
445
 
114
- throw new FileSystemAccessError(e.message, e);
446
+ throw new MultiProcessMutexError(lockPath, e);
115
447
  }
116
448
 
117
- const now = new Date();
118
- const fileDate = new Date(fileStat.ctime);
119
- const diff = now.getTime() - fileDate.getTime();
449
+ // Best-effort cleanup of orphaned temp files from dead processes
450
+ this.#cleanupDeadProcessTempFiles();
120
451
 
121
- return diff > this.#mutexLifespanInMs;
452
+ return true;
122
453
  }
123
454
 
124
- #deleteMutexFile() {
455
+ /**
456
+ * Checks if a process with the given PID is alive using signal 0, which is
457
+ * a platform-independent existence check supported on both POSIX and Windows.
458
+ *
459
+ * `ESRCH` means the process doesn't exist. `EPERM` means it exists but
460
+ * belongs to another user — still alive.
461
+ */
462
+ #isProcessAlive(pid: number): boolean {
125
463
  try {
126
- log(`Deleting mutex file at path '${this.#mutexFilePath}'`);
127
- fs.unlinkSync(this.#mutexFilePath);
464
+ process.kill(pid, 0);
465
+ return true;
128
466
  } catch (e) {
129
467
  ensureNodeErrnoExceptionError(e);
130
-
131
- if (e.code === "ENOENT") {
132
- // The file might have been deleted by another process while this function was trying to access it.
133
- return;
468
+ if (e.code === "ESRCH") {
469
+ return false; // Process does not exist
134
470
  }
135
-
136
- throw new FileSystemAccessError(e.message, e);
471
+ // EPERM means the process exists but we don't have permission to signal it
472
+ return true;
137
473
  }
138
474
  }
139
475
 
140
- async #isMutexProcessOwnerDead(): Promise<boolean> {
141
- let mutexPid: string;
476
+ #buildMetadata(): LockMetadata {
477
+ return {
478
+ pid: process.pid,
479
+ hostname: os.hostname(),
480
+ createdAt: Date.now(),
481
+ ...(process.getuid !== undefined ? { uid: process.getuid() } : {}),
482
+ platform: process.platform,
483
+ sessionId: PROCESS_SESSION_ID,
484
+ };
485
+ }
142
486
 
487
+ #readMetadata(): LockMetadata | undefined {
143
488
  try {
144
- // If the file doesn't exist, it means the owning process deleted it
145
- mutexPid = await readUtf8File(this.#mutexFilePath);
146
- } catch (_e) {
147
- return false;
489
+ const content = fs.readFileSync(this.#lockFilePath, "utf8");
490
+ const parsed: unknown = JSON.parse(content);
491
+
492
+ if (
493
+ typeof parsed !== "object" ||
494
+ parsed === null ||
495
+ !("pid" in parsed) ||
496
+ !("hostname" in parsed) ||
497
+ !("createdAt" in parsed) ||
498
+ !("platform" in parsed) ||
499
+ typeof parsed.pid !== "number" ||
500
+ typeof parsed.hostname !== "string" ||
501
+ typeof parsed.createdAt !== "number" ||
502
+ typeof parsed.platform !== "string" ||
503
+ Number.isSafeInteger(parsed.pid) === false ||
504
+ parsed.pid < 1 ||
505
+ Number.isSafeInteger(parsed.createdAt) === false ||
506
+ parsed.createdAt < 1 ||
507
+ ("uid" in parsed &&
508
+ parsed.uid !== undefined &&
509
+ (typeof parsed.uid !== "number" ||
510
+ Number.isSafeInteger(parsed.uid) === false)) ||
511
+ ("sessionId" in parsed && typeof parsed.sessionId !== "string")
512
+ ) {
513
+ return undefined;
514
+ }
515
+
516
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We just validated it
517
+ return parsed as LockMetadata;
518
+ } catch {
519
+ // Missing file, corrupt JSON, permission error — all treated as "no valid metadata"
520
+ return undefined;
148
521
  }
522
+ }
523
+
524
+ /**
525
+ * Best-effort cleanup of orphaned temp files left by dead processes.
526
+ *
527
+ * Scans the parent directory for all temp files matching this lock's naming
528
+ * pattern (`{baseName}.tmp-{pid}-...`), parses the PID from each filename,
529
+ * and removes files whose PID is no longer alive. Files with unparseable
530
+ * PIDs are left untouched (conservative — don't delete what we can't verify).
531
+ *
532
+ * This is safe because the class is single-host-only (cross-host usage
533
+ * throws `IncompatibleHostnameMultiProcessMutexError`).
534
+ */
535
+ #cleanupDeadProcessTempFiles(): void {
536
+ const parentDir = path.dirname(this.#lockFilePath);
537
+ const baseName = path.basename(this.#lockFilePath);
538
+ const prefix = `${baseName}.tmp-`;
149
539
 
150
540
  try {
151
- process.kill(parseInt(mutexPid, 10), 0);
152
- } catch (e) {
153
- ensureNodeErrnoExceptionError(e);
541
+ const entries = fs.readdirSync(parentDir);
542
+ for (const entry of entries) {
543
+ if (!entry.startsWith(prefix)) {
544
+ continue;
545
+ }
154
546
 
155
- if (e.code === "ESRCH") {
156
- // The process owning the mutex no longer exists
157
- return true;
547
+ // Parse PID from filename: {baseName}.tmp-{pid}-{sessionId}-{ts}-{rand}
548
+ const afterPrefix = entry.slice(prefix.length);
549
+ const pidStr = afterPrefix.split("-")[0];
550
+ const pid = Number(pidStr);
551
+
552
+ if (!Number.isSafeInteger(pid) || pid < 1) {
553
+ // Can't verify liveness — leave file alone
554
+ continue;
555
+ }
556
+
557
+ if (this.#isProcessAlive(pid)) {
558
+ continue;
559
+ }
560
+
561
+ try {
562
+ fs.unlinkSync(path.join(parentDir, entry));
563
+ log(`Cleaned up orphaned temp file: ${entry}`);
564
+ } catch {
565
+ // Best effort
566
+ }
158
567
  }
568
+ } catch {
569
+ // Best effort — parent directory may not be readable
159
570
  }
160
-
161
- return false;
162
571
  }
163
572
  }
164
573