'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, };