@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.
- package/CHANGELOG.md +17 -0
- package/dist/src/errors/synchronization.d.ts +29 -0
- package/dist/src/errors/synchronization.d.ts.map +1 -0
- package/dist/src/errors/synchronization.js +48 -0
- package/dist/src/errors/synchronization.js.map +1 -0
- package/dist/src/format.d.ts +7 -41
- package/dist/src/format.d.ts.map +1 -1
- package/dist/src/format.js +2 -72
- package/dist/src/format.js.map +1 -1
- package/dist/src/global-dir.d.ts +16 -0
- package/dist/src/global-dir.d.ts.map +1 -1
- package/dist/src/global-dir.js +27 -0
- package/dist/src/global-dir.js.map +1 -1
- package/dist/src/internal/format.d.ts +3 -3
- package/dist/src/internal/format.d.ts.map +1 -1
- package/dist/src/internal/format.js.map +1 -1
- package/dist/src/internal/request.d.ts.map +1 -1
- package/dist/src/internal/request.js +2 -1
- package/dist/src/internal/request.js.map +1 -1
- package/dist/src/lang.d.ts +1 -0
- package/dist/src/lang.d.ts.map +1 -1
- package/dist/src/lang.js +13 -0
- package/dist/src/lang.js.map +1 -1
- package/dist/src/request.d.ts.map +1 -1
- package/dist/src/request.js +24 -6
- package/dist/src/request.js.map +1 -1
- package/dist/src/synchronization.d.ts +99 -1
- package/dist/src/synchronization.d.ts.map +1 -1
- package/dist/src/synchronization.js +407 -89
- package/dist/src/synchronization.js.map +1 -1
- package/package.json +1 -1
- package/src/errors/synchronization.ts +75 -0
- package/src/format.ts +11 -94
- package/src/global-dir.ts +31 -0
- package/src/internal/format.ts +3 -3
- package/src/internal/request.ts +2 -1
- package/src/lang.ts +15 -0
- package/src/request.ts +29 -6
- package/src/synchronization.ts +504 -95
package/src/synchronization.ts
CHANGED
|
@@ -1,164 +1,573 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 {
|
|
18
|
-
|
|
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 #
|
|
27
|
-
readonly #
|
|
136
|
+
readonly #lockFilePath: string;
|
|
137
|
+
readonly #timeout: number;
|
|
138
|
+
readonly #initialPollInterval: number;
|
|
28
139
|
|
|
29
|
-
|
|
30
|
-
|
|
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.#
|
|
33
|
-
this.#
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
223
|
+
const result = this.#tryAcquire();
|
|
224
|
+
|
|
225
|
+
if (result.acquired) {
|
|
226
|
+
return;
|
|
44
227
|
}
|
|
45
228
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
80
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
363
|
+
#handleExistingLock(): AcquireResult {
|
|
364
|
+
const staleness = this.#checkStaleness();
|
|
89
365
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
#
|
|
103
|
-
|
|
427
|
+
#tryUnlockingStaleLock(metadata: LockMetadata | undefined): boolean {
|
|
428
|
+
const lockPath = this.#lockFilePath;
|
|
429
|
+
|
|
104
430
|
try {
|
|
105
|
-
|
|
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
|
-
//
|
|
111
|
-
|
|
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
|
|
446
|
+
throw new MultiProcessMutexError(lockPath, e);
|
|
115
447
|
}
|
|
116
448
|
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
452
|
+
return true;
|
|
122
453
|
}
|
|
123
454
|
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
464
|
+
process.kill(pid, 0);
|
|
465
|
+
return true;
|
|
128
466
|
} catch (e) {
|
|
129
467
|
ensureNodeErrnoExceptionError(e);
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
541
|
+
const entries = fs.readdirSync(parentDir);
|
|
542
|
+
for (const entry of entries) {
|
|
543
|
+
if (!entry.startsWith(prefix)) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
154
546
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|