Files
cc-web/lib/codex-home-lock.js
2026-09-28 08:13:09 +08:00

190 lines
5.8 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const DEFAULT_LOCK_WAIT_MS = 30_000;
const DEFAULT_LOCK_POLL_MS = 200;
const DEFAULT_LOCK_POLL_MAX_MS = 2_000;
const INVALID_LOCK_GRACE_MS = 2_000;
function positiveInt(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
const parsed = Number.parseInt(String(value ?? ''), 10);
if (!Number.isFinite(parsed) || parsed < min) return fallback;
return Math.min(parsed, max);
}
function resolveCodexHome(env = process.env) {
const explicit = String(env?.CODEX_HOME || '').trim();
if (explicit) return path.resolve(explicit);
const home = String(env?.HOME || env?.USERPROFILE || os.homedir() || '').trim();
return home ? path.join(home, '.codex') : '';
}
function readProcessStartTime(pid) {
if (!Number.isInteger(pid) || pid <= 0 || process.platform === 'win32') return null;
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
const closingParen = stat.lastIndexOf(')');
if (closingParen < 0) return null;
const fields = stat.slice(closingParen + 1).trim().split(/\s+/);
// The remainder starts at stat field 3; field 22 is index 19 here.
return fields[19] || null;
} catch {
return null;
}
}
function currentProcessOwner() {
return {
pid: process.pid,
startTime: readProcessStartTime(process.pid),
token: crypto.randomBytes(16).toString('hex'),
createdAt: new Date().toISOString(),
};
}
function readLock(lockPath) {
try {
const stat = fs.statSync(lockPath);
let owner = null;
try {
const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
if (parsed && typeof parsed === 'object') owner = parsed;
} catch {}
return { owner, mtimeMs: stat.mtimeMs };
} catch (error) {
if (error?.code === 'ENOENT') return null;
return { owner: null, mtimeMs: 0 };
}
}
function processAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === 'EPERM';
}
}
function lockOwnerActive(owner) {
if (!owner || !Number.isInteger(Number(owner.pid))) return false;
const pid = Number(owner.pid);
if (!processAlive(pid)) return false;
const recordedStart = owner.startTime == null ? null : String(owner.startTime);
const currentStart = readProcessStartTime(pid);
if (recordedStart && currentStart && recordedStart !== currentStart) return false;
// On platforms without /proc, a live PID is the strongest available signal.
return true;
}
function lockOwnerSummary(owner) {
if (!owner || typeof owner !== 'object') return null;
return {
pid: Number.isInteger(Number(owner.pid)) ? Number(owner.pid) : null,
startTime: owner.startTime || null,
createdAt: owner.createdAt || null,
};
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function acquireCodexHomeLock(options = {}) {
const lockPath = options.lockPath
? path.resolve(String(options.lockPath))
: (() => {
const home = resolveCodexHome(options.env || process.env);
return home ? path.join(home, '.cc-web-codex.lock') : '';
})();
if (!lockPath) {
return { path: '', owner: null, release: async () => {} };
}
const waitMs = positiveInt(
options.waitMs ?? options.env?.CC_WEB_CODEX_LOCK_WAIT_MS,
DEFAULT_LOCK_WAIT_MS,
{ min: 0, max: 10 * 60 * 1000 },
);
const pollMs = positiveInt(options.pollMs, DEFAULT_LOCK_POLL_MS, { min: 10, max: 5000 });
const pollMaxMs = positiveInt(options.pollMaxMs, DEFAULT_LOCK_POLL_MAX_MS, { min: pollMs, max: 10_000 });
const owner = currentProcessOwner();
const startedAt = Date.now();
let lastOwner = null;
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
while (true) {
try {
const fd = fs.openSync(lockPath, 'wx', 0o600);
try {
fs.writeFileSync(fd, `${JSON.stringify(owner)}\n`, 'utf8');
} finally {
fs.closeSync(fd);
}
return createLockHandle(lockPath, owner);
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
const existing = readLock(lockPath);
lastOwner = existing?.owner || null;
const ageMs = existing ? Math.max(0, Date.now() - Number(existing.mtimeMs || 0)) : 0;
const stale = existing && (
(existing.owner && !lockOwnerActive(existing.owner))
|| (!existing.owner && ageMs >= INVALID_LOCK_GRACE_MS)
);
if (stale) {
try {
const latest = readLock(lockPath);
if (latest?.owner?.token === existing.owner?.token || (!latest?.owner && ageMs >= INVALID_LOCK_GRACE_MS)) {
fs.unlinkSync(lockPath);
continue;
}
} catch (unlinkError) {
if (unlinkError?.code === 'ENOENT') continue;
}
}
if (Date.now() - startedAt >= waitMs) {
const err = new Error(`Codex CODEX_HOME 已被占用: ${lockPath}`);
err.code = 'CODEX_HOME_LOCK_BUSY';
err.lockPath = lockPath;
err.owner = lockOwnerSummary(lastOwner);
throw err;
}
const elapsed = Date.now() - startedAt;
const base = Math.min(pollMaxMs, pollMs * (2 ** Math.min(4, Math.floor(elapsed / 1000))));
const jitter = Math.floor(Math.random() * Math.max(1, Math.min(100, base * 0.2)));
await wait(Math.min(pollMaxMs, base + jitter));
}
}
}
function createLockHandle(lockPath, owner) {
let released = false;
return {
path: lockPath,
owner,
release: async () => {
if (released) return;
released = true;
try {
const onDisk = readLock(lockPath)?.owner;
if (onDisk?.token === owner.token) fs.unlinkSync(lockPath);
} catch {}
},
};
}
module.exports = {
DEFAULT_LOCK_WAIT_MS,
acquireCodexHomeLock,
lockOwnerActive,
readProcessStartTime,
resolveCodexHome,
};