refactor(auth-profiles): replace proper-lockfile with custom file lock
Unify locking strategy across the project by using a custom synchronous file lock (exclusive-create based, with PID stale detection) instead of the proper-lockfile dependency, matching the pattern in session-write-lock. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b10420324
commit
617ddfbfea
3 changed files with 98 additions and 51 deletions
|
|
@ -31,7 +31,6 @@
|
|||
"packageManager": "pnpm@10.28.2",
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
|
|
@ -62,7 +61,6 @@
|
|||
"pino": "^10.3.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"socket.io": "^4.8.3",
|
||||
|
|
|
|||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
|
|
@ -9,24 +9,9 @@ catalogs:
|
|||
'@types/node':
|
||||
specifier: ^25.0.10
|
||||
version: 25.0.10
|
||||
'@types/react':
|
||||
specifier: ^19
|
||||
version: 19.2.10
|
||||
'@types/react-dom':
|
||||
specifier: ^19
|
||||
version: 19.2.3
|
||||
react:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3
|
||||
react-dom:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
zustand:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.10
|
||||
|
||||
importers:
|
||||
|
||||
|
|
@ -89,9 +74,6 @@ importers:
|
|||
pino-pretty:
|
||||
specifier: ^13.1.3
|
||||
version: 13.1.3
|
||||
proper-lockfile:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
|
|
@ -120,9 +102,6 @@ importers:
|
|||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 25.0.10
|
||||
'@types/proper-lockfile':
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.4
|
||||
'@types/turndown':
|
||||
specifier: ^5.0.6
|
||||
version: 5.0.6
|
||||
|
|
@ -3341,9 +3320,6 @@ packages:
|
|||
'@types/plist@3.0.5':
|
||||
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
|
||||
|
||||
'@types/proper-lockfile@4.1.4':
|
||||
resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -3358,9 +3334,6 @@ packages:
|
|||
'@types/responselike@1.0.3':
|
||||
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
|
||||
|
||||
'@types/retry@0.12.5':
|
||||
resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==}
|
||||
|
||||
'@types/stack-utils@2.0.3':
|
||||
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
|
||||
|
||||
|
|
@ -12578,10 +12551,6 @@ snapshots:
|
|||
xmlbuilder: 15.1.1
|
||||
optional: true
|
||||
|
||||
'@types/proper-lockfile@4.1.4':
|
||||
dependencies:
|
||||
'@types/retry': 0.12.5
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.1.17)':
|
||||
dependencies:
|
||||
'@types/react': 19.1.17
|
||||
|
|
@ -12603,8 +12572,6 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 25.0.10
|
||||
|
||||
'@types/retry@0.12.5': {}
|
||||
|
||||
'@types/stack-utils@2.0.3': {}
|
||||
|
||||
'@types/statuses@2.0.6': {}
|
||||
|
|
|
|||
|
|
@ -3,30 +3,113 @@
|
|||
*
|
||||
* Persistence layer for auth profile runtime state.
|
||||
* Stores usage stats, cooldowns, and last-good info in ~/.super-multica/auth-profiles.json.
|
||||
* Uses proper-lockfile for safe concurrent access across multiple agent processes.
|
||||
* Uses a custom file lock (exclusive-create based) for safe concurrent access.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
closeSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
constants as fsConstants,
|
||||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { DATA_DIR } from "../../shared/paths.js";
|
||||
import { AUTH_STORE_VERSION, AUTH_PROFILE_STORE_FILENAME } from "./constants.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
// ============================================================
|
||||
// Lock options (matches OpenClaw's AUTH_STORE_LOCK_OPTIONS)
|
||||
// Custom file lock (synchronous, exclusive-create based)
|
||||
// ============================================================
|
||||
|
||||
const LOCK_OPTIONS = {
|
||||
retries: {
|
||||
retries: 10,
|
||||
factor: 2,
|
||||
minTimeout: 100,
|
||||
maxTimeout: 10_000,
|
||||
randomize: true,
|
||||
},
|
||||
stale: 30_000,
|
||||
} as const;
|
||||
const LOCK_STALE_MS = 30_000;
|
||||
const LOCK_RETRY_COUNT = 10;
|
||||
const LOCK_RETRY_BASE_MS = 50;
|
||||
const LOCK_RETRY_MAX_MS = 1_000;
|
||||
|
||||
type LockPayload = { pid: number; createdAt: string };
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
if (!Number.isFinite(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readLockPayloadSync(lockPath: string): LockPayload | null {
|
||||
try {
|
||||
const raw = readFileSync(lockPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<LockPayload>;
|
||||
if (typeof parsed.pid !== "number" || typeof parsed.createdAt !== "string") return null;
|
||||
return { pid: parsed.pid, createdAt: parsed.createdAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLockStale(lockPath: string): boolean {
|
||||
const payload = readLockPayloadSync(lockPath);
|
||||
if (payload) {
|
||||
const age = Date.now() - Date.parse(payload.createdAt);
|
||||
if (!Number.isFinite(age) || age > LOCK_STALE_MS) return true;
|
||||
return !isProcessAlive(payload.pid);
|
||||
}
|
||||
// No payload readable — check file mtime
|
||||
try {
|
||||
const stat = statSync(lockPath);
|
||||
return Date.now() - stat.mtimeMs > LOCK_STALE_MS;
|
||||
} catch {
|
||||
return true; // Can't stat — treat as stale
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a synchronous exclusive file lock.
|
||||
* Returns a release function. Throws if lock cannot be acquired after retries.
|
||||
*/
|
||||
function acquireLockSync(filePath: string): () => void {
|
||||
const lockPath = `${filePath}.lock`;
|
||||
const payload = JSON.stringify(
|
||||
{ pid: process.pid, createdAt: new Date().toISOString() },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
for (let attempt = 0; attempt < LOCK_RETRY_COUNT; attempt++) {
|
||||
try {
|
||||
// O_WRONLY | O_CREAT | O_EXCL — fails if file already exists
|
||||
const fd = openSync(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL);
|
||||
writeFileSync(fd, payload, "utf8");
|
||||
closeSync(fd);
|
||||
return () => {
|
||||
try { rmSync(lockPath, { force: true }); } catch { /* best effort */ }
|
||||
};
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code !== "EEXIST") throw err;
|
||||
|
||||
// Lock file exists — check if stale
|
||||
if (isLockStale(lockPath)) {
|
||||
try { rmSync(lockPath, { force: true }); } catch { /* ignore */ }
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait and retry (synchronous busy-wait via Atomics for minimal overhead)
|
||||
const delay = Math.min(LOCK_RETRY_MAX_MS, LOCK_RETRY_BASE_MS * (attempt + 1));
|
||||
const buf = new SharedArrayBuffer(4);
|
||||
Atomics.wait(new Int32Array(buf), 0, 0, delay);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to acquire lock after ${LOCK_RETRY_COUNT} retries: ${filePath}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Paths
|
||||
|
|
@ -112,8 +195,7 @@ export function updateAuthProfileStore(
|
|||
const storePath = ensureAuthStoreFile();
|
||||
|
||||
try {
|
||||
// Acquire file lock
|
||||
const release = lockfile.lockSync(storePath, LOCK_OPTIONS);
|
||||
const release = acquireLockSync(storePath);
|
||||
try {
|
||||
const store = loadAuthProfileStore();
|
||||
updater(store);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue