@nomicfoundation/hardhat-utils 3.0.6 → 4.0.0

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