@ericsanchezok/synergy-util 3.0.20 → 3.0.22
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/dist/fs-lock.js +132 -39
- package/dist/process-identity.d.ts +24 -0
- package/dist/process-identity.js +109 -0
- package/package.json +3 -2
package/dist/fs-lock.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { processStartIdentity } from "./process-identity";
|
|
4
5
|
const DEFAULT_RETRY_MS = 25;
|
|
5
6
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
6
7
|
const DEFAULT_STALE_METADATA_MS = 5_000;
|
|
8
|
+
const VERIFIED_LIVE_TTL_MS = 1_000;
|
|
7
9
|
export const HOLOS_ACCOUNTS_WRITE_LOCK_KEY = "holos-accounts:write";
|
|
8
10
|
export const LEGACY_API_KEY_WRITE_LOCK_KEY = "legacy-api-key:write";
|
|
9
11
|
export function authLockDirectory(synergyRoot) {
|
|
@@ -30,57 +32,148 @@ async function acquireFileLock(options) {
|
|
|
30
32
|
await fs.chmod(options.directory, 0o700);
|
|
31
33
|
const filename = fileLockPath(options.directory, options.key);
|
|
32
34
|
const startedAt = Date.now();
|
|
35
|
+
const startIdentity = await processStartIdentity(process.pid);
|
|
36
|
+
const ownerToken = randomUUID();
|
|
37
|
+
const verifiedLiveUntil = new Map();
|
|
33
38
|
while (true) {
|
|
39
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
40
|
+
throw new Error(options.timeoutMessage ?? `Timed out acquiring file lock for ${options.key}`);
|
|
41
|
+
}
|
|
42
|
+
let handle;
|
|
34
43
|
try {
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
await handle.writeFile(JSON.stringify({ pid: process.pid, acquiredAt: Date.now() }));
|
|
38
|
-
}
|
|
39
|
-
catch (error) {
|
|
40
|
-
await fs.unlink(filename).catch(() => { });
|
|
41
|
-
throw error;
|
|
42
|
-
}
|
|
43
|
-
finally {
|
|
44
|
-
await handle.close();
|
|
45
|
-
}
|
|
46
|
-
return {
|
|
47
|
-
async release() {
|
|
48
|
-
await fs.unlink(filename).catch((error) => {
|
|
49
|
-
if (error.code !== "ENOENT")
|
|
50
|
-
throw error;
|
|
51
|
-
});
|
|
52
|
-
},
|
|
53
|
-
};
|
|
44
|
+
handle = await fs.open(filename, "wx", 0o600);
|
|
54
45
|
}
|
|
55
46
|
catch (error) {
|
|
56
|
-
|
|
47
|
+
const code = error.code;
|
|
48
|
+
// Provenance: DeleteFileW delete-pending semantics — an open on a
|
|
49
|
+
// delete-pending entry fails with access-denied until the last handle
|
|
50
|
+
// closes ( https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-deletefilew ).
|
|
51
|
+
// Local adaptation: libuv maps that failure to EPERM, so an exclusive
|
|
52
|
+
// create can see EPERM instead of EEXIST right after a release — treat
|
|
53
|
+
// it as contention and retry. Only the create itself qualifies:
|
|
54
|
+
// EPERM from payload writes or close stays a caller-visible failure,
|
|
55
|
+
// bounded here by the top-of-loop timeout check.
|
|
56
|
+
if (code !== "EEXIST" && code !== "EPERM")
|
|
57
57
|
throw error;
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
await
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
if (!owner?.pid) {
|
|
64
|
-
const stat = await fs.stat(filename).catch(() => undefined);
|
|
65
|
-
if (stat && Date.now() - stat.mtimeMs > staleMetadataMs) {
|
|
66
|
-
await fs.unlink(filename).catch(() => { });
|
|
58
|
+
const snapshot = await readOwnerSnapshot(filename);
|
|
59
|
+
if (snapshot && (await isStaleOwner(filename, snapshot, staleMetadataMs, verifiedLiveUntil))) {
|
|
60
|
+
if (await removeStaleLock(filename, snapshot.contents))
|
|
67
61
|
continue;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
if (Date.now() - startedAt >= timeoutMs) {
|
|
71
|
-
throw new Error(options.timeoutMessage ?? `Timed out acquiring file lock for ${options.key}`);
|
|
72
62
|
}
|
|
73
63
|
await sleep(retryMs);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, acquiredAt: Date.now(), startIdentity, ownerToken }));
|
|
74
68
|
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
await fs.unlink(filename).catch(() => { });
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await handle.close();
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
release: () => releaseOwnedLock(filename, ownerToken),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function readOwnerSnapshot(filename) {
|
|
82
|
+
const contents = await fs.readFile(filename, "utf8").catch(() => undefined);
|
|
83
|
+
if (contents === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
try {
|
|
86
|
+
const value = JSON.parse(contents);
|
|
87
|
+
if (!value || typeof value !== "object")
|
|
88
|
+
return { contents };
|
|
89
|
+
const pid = value.pid;
|
|
90
|
+
const startIdentity = value.startIdentity;
|
|
91
|
+
return {
|
|
92
|
+
contents,
|
|
93
|
+
owner: {
|
|
94
|
+
pid: typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : undefined,
|
|
95
|
+
startIdentity: typeof startIdentity === "string" ? startIdentity : undefined,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { contents };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A live pid proves nothing: pids recycle on every platform, and on Windows a
|
|
105
|
+
* recycled pid makes a dead owner look alive forever. The recorded start
|
|
106
|
+
* identity settles whether the pid still belongs to the process that took the
|
|
107
|
+
* lock — deterministically, without ever displacing a live owner by age.
|
|
108
|
+
*/
|
|
109
|
+
async function isStaleOwner(filename, snapshot, staleMetadataMs, verifiedLiveUntil) {
|
|
110
|
+
const owner = snapshot.owner;
|
|
111
|
+
if (owner?.pid === undefined) {
|
|
112
|
+
const stat = await fs.stat(filename).catch(() => undefined);
|
|
113
|
+
return !!stat && Date.now() - stat.mtimeMs > staleMetadataMs;
|
|
114
|
+
}
|
|
115
|
+
if (!processExists(owner.pid))
|
|
116
|
+
return true;
|
|
117
|
+
if (owner.startIdentity === undefined)
|
|
118
|
+
return false;
|
|
119
|
+
const key = `${owner.pid}:${owner.startIdentity}`;
|
|
120
|
+
const verifiedUntil = verifiedLiveUntil.get(key);
|
|
121
|
+
if (verifiedUntil !== undefined && verifiedUntil > Date.now())
|
|
122
|
+
return false;
|
|
123
|
+
const current = await processStartIdentity(owner.pid);
|
|
124
|
+
if (current === undefined || current === owner.startIdentity) {
|
|
125
|
+
// The owner was verified live. Cache the verdict briefly so the identity
|
|
126
|
+
// query (a subprocess on Windows) is not spawned on every retry, but
|
|
127
|
+
// recheck once it expires: the owner may exit and its pid be recycled
|
|
128
|
+
// while this acquisition attempt is still waiting.
|
|
129
|
+
verifiedLiveUntil.set(key, Date.now() + VERIFIED_LIVE_TTL_MS);
|
|
130
|
+
return false;
|
|
75
131
|
}
|
|
132
|
+
return true;
|
|
76
133
|
}
|
|
77
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Remove the lock file only while its contents still match the snapshot the
|
|
136
|
+
* stale decision was based on. Read-compare-unlink, never rename-vacate: a
|
|
137
|
+
* rename briefly removes the canonical path, and when several waiters observe
|
|
138
|
+
* the same stale payload a delayed rename can displace a successor's fresh
|
|
139
|
+
* lock and then discard it on a failed restore — overlapping critical
|
|
140
|
+
* sections. The residual read-to-unlink window is far narrower and only
|
|
141
|
+
* deletes a lock that was replaced in that instant, not a lock we moved.
|
|
142
|
+
*/
|
|
143
|
+
async function removeStaleLock(filename, expectedContents) {
|
|
144
|
+
const current = await fs.readFile(filename, "utf8").catch(() => undefined);
|
|
145
|
+
if (current !== expectedContents)
|
|
146
|
+
return false;
|
|
147
|
+
try {
|
|
148
|
+
await fs.unlink(filename);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
// ENOENT: another waiter already reclaimed it — treat as removed.
|
|
153
|
+
return error.code === "ENOENT";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function releaseOwnedLock(filename, ownerToken) {
|
|
157
|
+
const contents = await fs.readFile(filename, "utf8").catch(() => undefined);
|
|
158
|
+
if (contents === undefined || readOwnerToken(contents) !== ownerToken)
|
|
159
|
+
return;
|
|
160
|
+
try {
|
|
161
|
+
await fs.unlink(filename);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error.code !== "ENOENT")
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function readOwnerToken(contents) {
|
|
169
|
+
if (contents === undefined)
|
|
170
|
+
return undefined;
|
|
78
171
|
try {
|
|
79
|
-
const
|
|
80
|
-
if (!
|
|
172
|
+
const value = JSON.parse(contents);
|
|
173
|
+
if (!value || typeof value !== "object")
|
|
81
174
|
return undefined;
|
|
82
|
-
const
|
|
83
|
-
return typeof
|
|
175
|
+
const ownerToken = value.ownerToken;
|
|
176
|
+
return typeof ownerToken === "string" ? ownerToken : undefined;
|
|
84
177
|
}
|
|
85
178
|
catch {
|
|
86
179
|
return undefined;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity of a pid beyond the number itself: the moment the occupying process
|
|
3
|
+
* started. Pids recycle on every platform, so a recorded pid matching a live
|
|
4
|
+
* process does not prove that process is the recorded one — the same pid held
|
|
5
|
+
* by a process with a different start time does. Returns `undefined` when the
|
|
6
|
+
* start time cannot be determined; callers must treat that as "unknown", never
|
|
7
|
+
* as "recycled".
|
|
8
|
+
*
|
|
9
|
+
* Every platform has one canonical encoding, so a recorded identity is always
|
|
10
|
+
* comparable with a later query on the same machine. Linux identities include
|
|
11
|
+
* the boot id because start ticks reset on reboot while lock files survive it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function processStartIdentity(pid: number): Promise<string | undefined>;
|
|
14
|
+
/**
|
|
15
|
+
* WMIC reports the creation time as local wall clock plus a UTC offset in
|
|
16
|
+
* minutes (`YYYYMMDDHHMMSS.microseconds±UUU`). Convert to UTC epoch
|
|
17
|
+
* milliseconds so the value stays comparable with the PowerShell fallback,
|
|
18
|
+
* which reports .NET ticks — the two sources must share one encoding or a
|
|
19
|
+
* live owner looks like a recycled pid when a later query takes the other
|
|
20
|
+
* path.
|
|
21
|
+
*/
|
|
22
|
+
export declare function wmicCreationDateToEpochMs(value: string): number | undefined;
|
|
23
|
+
/** .NET ticks (100 ns since 0001-01-01) → UTC epoch milliseconds. */
|
|
24
|
+
export declare function ticksToEpochMs(value: string): number | undefined;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
let ownIdentityPromise;
|
|
6
|
+
let linuxBootIdPromise;
|
|
7
|
+
/**
|
|
8
|
+
* Identity of a pid beyond the number itself: the moment the occupying process
|
|
9
|
+
* started. Pids recycle on every platform, so a recorded pid matching a live
|
|
10
|
+
* process does not prove that process is the recorded one — the same pid held
|
|
11
|
+
* by a process with a different start time does. Returns `undefined` when the
|
|
12
|
+
* start time cannot be determined; callers must treat that as "unknown", never
|
|
13
|
+
* as "recycled".
|
|
14
|
+
*
|
|
15
|
+
* Every platform has one canonical encoding, so a recorded identity is always
|
|
16
|
+
* comparable with a later query on the same machine. Linux identities include
|
|
17
|
+
* the boot id because start ticks reset on reboot while lock files survive it.
|
|
18
|
+
*/
|
|
19
|
+
export async function processStartIdentity(pid) {
|
|
20
|
+
if (pid === process.pid) {
|
|
21
|
+
// Cache the promise, not the value: concurrent first acquisitions must
|
|
22
|
+
// share a single query instead of spawning one subprocess each.
|
|
23
|
+
ownIdentityPromise ??= queryProcessStartIdentity(pid);
|
|
24
|
+
return ownIdentityPromise;
|
|
25
|
+
}
|
|
26
|
+
return queryProcessStartIdentity(pid);
|
|
27
|
+
}
|
|
28
|
+
async function queryProcessStartIdentity(pid) {
|
|
29
|
+
if (process.platform === "linux") {
|
|
30
|
+
const stat = await fs.readFile(`/proc/${pid}/stat`, "utf8").catch(() => "");
|
|
31
|
+
const closingParen = stat.lastIndexOf(")");
|
|
32
|
+
const fields = closingParen < 0
|
|
33
|
+
? []
|
|
34
|
+
: stat
|
|
35
|
+
.slice(closingParen + 1)
|
|
36
|
+
.trim()
|
|
37
|
+
.split(/\s+/);
|
|
38
|
+
const startTicks = fields[19];
|
|
39
|
+
if (!startTicks)
|
|
40
|
+
return undefined;
|
|
41
|
+
const bootId = await linuxBootId();
|
|
42
|
+
return bootId ? `linux:${bootId}:${startTicks}` : `linux:${startTicks}`;
|
|
43
|
+
}
|
|
44
|
+
if (process.platform === "win32") {
|
|
45
|
+
const wmic = await execFileAsync("wmic.exe", [
|
|
46
|
+
"process",
|
|
47
|
+
"where",
|
|
48
|
+
`(ProcessId=${pid})`,
|
|
49
|
+
"get",
|
|
50
|
+
"CreationDate",
|
|
51
|
+
"/value",
|
|
52
|
+
]).catch(() => ({ stdout: "" }));
|
|
53
|
+
const creationDate = wmic.stdout.match(/CreationDate=(\d{14}\.\d{6}[+-]\d{3})/)?.[1];
|
|
54
|
+
if (creationDate !== undefined) {
|
|
55
|
+
const epochMs = wmicCreationDateToEpochMs(creationDate);
|
|
56
|
+
if (epochMs !== undefined)
|
|
57
|
+
return `windows:${epochMs}`;
|
|
58
|
+
}
|
|
59
|
+
const result = await execFileAsync("powershell.exe", [
|
|
60
|
+
"-NoProfile",
|
|
61
|
+
"-NonInteractive",
|
|
62
|
+
"-Command",
|
|
63
|
+
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
|
|
64
|
+
]).catch(() => ({ stdout: "" }));
|
|
65
|
+
const epochMs = ticksToEpochMs(result.stdout);
|
|
66
|
+
if (epochMs !== undefined)
|
|
67
|
+
return `windows:${epochMs}`;
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
// `ps -o lstart=` resolves to whole seconds on macOS and BSD, so two
|
|
71
|
+
// processes started within the same second share an identity and a pid
|
|
72
|
+
// recycled within that second is not detected. That failure is fail-safe:
|
|
73
|
+
// the stale lock waits out the acquire timeout instead of a live owner
|
|
74
|
+
// being displaced.
|
|
75
|
+
const result = await execFileAsync("ps", ["-p", String(pid), "-o", "lstart="]).catch(() => ({ stdout: "" }));
|
|
76
|
+
const startedAt = Date.parse(result.stdout.trim());
|
|
77
|
+
return Number.isNaN(startedAt) ? undefined : `unix:${startedAt}`;
|
|
78
|
+
}
|
|
79
|
+
async function linuxBootId() {
|
|
80
|
+
linuxBootIdPromise ??= fs
|
|
81
|
+
.readFile("/proc/sys/kernel/random/boot_id", "utf8")
|
|
82
|
+
.then((value) => value.trim() || undefined)
|
|
83
|
+
.catch(() => undefined);
|
|
84
|
+
return linuxBootIdPromise;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* WMIC reports the creation time as local wall clock plus a UTC offset in
|
|
88
|
+
* minutes (`YYYYMMDDHHMMSS.microseconds±UUU`). Convert to UTC epoch
|
|
89
|
+
* milliseconds so the value stays comparable with the PowerShell fallback,
|
|
90
|
+
* which reports .NET ticks — the two sources must share one encoding or a
|
|
91
|
+
* live owner looks like a recycled pid when a later query takes the other
|
|
92
|
+
* path.
|
|
93
|
+
*/
|
|
94
|
+
export function wmicCreationDateToEpochMs(value) {
|
|
95
|
+
const match = value.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{6})([+-]\d{3})$/);
|
|
96
|
+
if (!match)
|
|
97
|
+
return undefined;
|
|
98
|
+
const [, year, month, day, hour, minute, second, micros, offset] = match;
|
|
99
|
+
const offsetMinutes = Number(offset);
|
|
100
|
+
return (Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second), Number(micros.slice(0, 3))) -
|
|
101
|
+
offsetMinutes * 60_000);
|
|
102
|
+
}
|
|
103
|
+
/** .NET ticks (100 ns since 0001-01-01) → UTC epoch milliseconds. */
|
|
104
|
+
export function ticksToEpochMs(value) {
|
|
105
|
+
const match = value.trim().match(/^\d+$/);
|
|
106
|
+
if (!match)
|
|
107
|
+
return undefined;
|
|
108
|
+
return Number((BigInt(match[0]) - 621355968000000000n) / 10000n);
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ericsanchezok/synergy-util",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.22",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"exports": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
14
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json"
|
|
14
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
15
|
+
"test": "bun test test/*.test.ts"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"dist"
|