@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.
- package/CHANGELOG.md +11 -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/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/request.ts +29 -6
- package/src/synchronization.ts +504 -95
|
@@ -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
|
-
|
|
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":"
|
|
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
|
-
|
|
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
|
-
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 {
|
|
15
|
-
import {
|
|
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
|
|
19
|
-
const
|
|
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
|
-
#
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
185
|
+
#releaseLock() {
|
|
53
186
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
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 === "
|
|
63
|
-
|
|
64
|
-
return
|
|
192
|
+
if (e.code === "ENOENT") {
|
|
193
|
+
log(`Lock at ${this.#lockFilePath} already removed`);
|
|
194
|
+
return;
|
|
65
195
|
}
|
|
66
|
-
throw new
|
|
196
|
+
throw new MultiProcessMutexError(this.#lockFilePath, e);
|
|
67
197
|
}
|
|
68
198
|
}
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
211
|
+
fs.accessSync(lockPath, fs.constants.F_OK);
|
|
212
|
+
lockExists = true;
|
|
73
213
|
}
|
|
74
|
-
|
|
75
|
-
//
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
//
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
/**
|