@ericsanchezok/synergy-util 3.0.21 → 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 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
- const handle = await fs.open(filename, "wx", 0o600);
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
- if (error.code !== "EEXIST")
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 owner = await readLockOwner(filename);
59
- if (owner?.pid && !processExists(owner.pid)) {
60
- await fs.unlink(filename).catch(() => { });
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
- async function readLockOwner(filename) {
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 owner = JSON.parse(await fs.readFile(filename, "utf8"));
80
- if (!owner || typeof owner !== "object")
172
+ const value = JSON.parse(contents);
173
+ if (!value || typeof value !== "object")
81
174
  return undefined;
82
- const pid = owner.pid;
83
- return typeof pid === "number" ? { pid } : undefined;
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.21",
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"