From 617ddfbfeab36adebeea316a27e75899f287bf4a Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 3 Feb 2026 18:05:00 +0800 Subject: [PATCH] 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 --- package.json | 2 - pnpm-lock.yaml | 33 --------- src/agent/auth-profiles/store.ts | 114 ++++++++++++++++++++++++++----- 3 files changed, 98 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index d0952bbf..fe1d242a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 035c9790..7e74a4c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {} diff --git a/src/agent/auth-profiles/store.ts b/src/agent/auth-profiles/store.ts index 1d0ab0ca..f50f2788 100644 --- a/src/agent/auth-profiles/store.ts +++ b/src/agent/auth-profiles/store.ts @@ -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; + 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);