fix(cliproxy): prevent port race condition with unified detection and startup lock

Add proxy-detector.ts for unified detection with multiple fallbacks:
- HTTP health check (most reliable)
- Session lock file check
- Port process detection with HTTP retry (handles Windows PID-XXXXX)

Add startup-lock.ts for file-based mutex:
- 10s timeout with 20 retries at 250ms intervals
- Stale lock detection via PID check
- Cross-platform support

Update cliproxy-executor.ts and service-manager.ts to use new modules.

Fixes race conditions when running simultaneous ccs commands.
This commit is contained in:
kaitranntt
2025-12-19 10:50:25 -05:00
parent 4db2a4915e
commit 96f17e3afb
5 changed files with 650 additions and 159 deletions
+67 -60
View File
@@ -43,19 +43,14 @@ import {
getDefaultAccount,
} from './account-manager';
import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands';
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
import {
ensureMcpWebSearch,
installWebSearchHook,
displayWebSearchStatus,
} from '../utils/websearch-manager';
import {
getExistingProxy,
registerSession,
unregisterSession,
hasActiveSessions,
cleanupOrphanedSessions,
} from './session-tracker';
import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker';
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
import { withStartupLock } from './startup-lock';
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
@@ -397,76 +392,88 @@ export async function execClaudeWithCLIProxy(
const configPath = generateConfig(provider, cfg.port);
log(`Config written: ${configPath}`);
// 6a. Pre-flight check: handle existing proxy or port conflicts
// Clean up orphaned sessions first (from crashed proxies)
// 6a. Pre-flight check using unified detection with startup lock
// This prevents race conditions when multiple CCS processes start simultaneously
cleanupOrphanedSessions(cfg.port);
// Check if there's an existing healthy proxy we can reuse
const existingProxy = getExistingProxy(cfg.port);
// Use startup lock to coordinate with other CCS processes
await withStartupLock(async () => {
// Detect running proxy using multiple methods (HTTP, session-lock, port-process)
const proxyStatus = await detectRunningProxy(cfg.port);
log(`Proxy detection: ${JSON.stringify(proxyStatus)}`);
if (existingProxy) {
// Reuse existing proxy - another CCS session started it
log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`);
sessionId = registerSession(cfg.port, existingProxy.pid);
console.log(
info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
);
} else {
// No existing proxy - check if port is free
const portProcess = await getPortProcess(cfg.port);
if (portProcess) {
if (isCLIProxyProcess(portProcess)) {
// CLIProxy on port but no session lock - likely orphaned/zombie
// Only kill if no active sessions registered
if (!hasActiveSessions()) {
log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`);
const killed = killProcessOnPort(cfg.port, verbose);
if (killed) {
console.log(info(`Cleaned up zombie CLIProxy process`));
// Wait a bit for port to be released
await new Promise((r) => setTimeout(r, 500));
}
} else {
// Active sessions exist but getExistingProxy returned null - something's wrong
// Try to connect anyway
log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`);
}
if (proxyStatus.running && proxyStatus.verified) {
// Healthy proxy found - join it
if (proxyStatus.pid) {
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined;
}
if (sessionId) {
console.log(info(`Joined existing CLIProxy on port ${cfg.port} (${proxyStatus.method})`));
} else {
// Non-CLIProxy process blocking the port - warn user
console.error('');
console.error(
warn(
`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`
)
);
console.error('');
console.error('To fix this, close the blocking application or run:');
console.error(` ${getPortCheckCommand(cfg.port)}`);
console.error('');
throw new Error(`Port ${cfg.port} is in use by another application`);
// Failed to register session, but proxy is running - continue anyway
log(`Failed to register session but proxy is healthy, continuing...`);
}
return; // Exit lock early, skip spawning
}
if (proxyStatus.running && !proxyStatus.verified) {
// Proxy detected but not ready yet (another process is starting it)
log(`Proxy starting up (detected via ${proxyStatus.method}), waiting...`);
const becameHealthy = await waitForProxyHealthy(cfg.port, cfg.timeout);
if (becameHealthy) {
if (proxyStatus.pid) {
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined;
}
console.log(info(`Joined CLIProxy after startup wait`));
return; // Exit lock early
}
// Proxy didn't become healthy - kill and respawn
if (proxyStatus.pid) {
log(`Proxy PID ${proxyStatus.pid} not responding, killing...`);
killProcessOnPort(cfg.port, verbose);
await new Promise((r) => setTimeout(r, 500));
}
}
// 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy)
// Use detached mode so proxy persists after terminal closes
const configPath = generateConfig(provider, cfg.port);
const proxyArgs = ['--config', configPath];
if (proxyStatus.blocked && proxyStatus.blocker) {
// Port blocked by non-CLIProxy process
// Last resort: try HTTP health check (handles Windows PID-XXXXX case)
const isActuallyOurs = await waitForProxyHealthy(cfg.port, 1000);
if (isActuallyOurs) {
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid) ?? undefined;
console.log(info(`Reclaimed CLIProxy with unrecognized process name`));
return;
}
// Truly blocked by another application
console.error('');
console.error(
warn(
`Port ${cfg.port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})`
)
);
console.error('');
console.error('To fix this, close the blocking application or run:');
console.error(` ${getPortCheckCommand(cfg.port)}`);
console.error('');
throw new Error(`Port ${cfg.port} is in use by another application`);
}
// 6b. Spawn CLIProxyAPI binary
const proxyArgs = ['--config', configPath];
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
proxy = spawn(binaryPath as string, proxyArgs, {
stdio: ['ignore', 'ignore', 'ignore'],
detached: true, // Persist after parent terminal closes
detached: true,
env: {
...process.env,
WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/
WRITABLE_PATH: getCliproxyWritablePath(),
},
});
// Unref so parent process can exit independently
proxy.unref();
// Handle proxy errors (only fires if spawn itself fails)
proxy.on('error', (error) => {
console.error(fail(`CLIProxy spawn error: ${error.message}`));
});
@@ -504,7 +511,7 @@ export async function execClaudeWithCLIProxy(
// Register this session with the new proxy
sessionId = registerSession(cfg.port, proxy.pid as number);
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`);
}
});
}
// 7. Execute Claude CLI with proxied environment
+8
View File
@@ -126,3 +126,11 @@ export {
// Service manager (background CLIProxy for dashboard)
export type { ServiceStartResult } from './service-manager';
export { ensureCliproxyService, stopCliproxyService, getServiceStatus } from './service-manager';
// Proxy detector (unified detection with multiple fallbacks)
export type { ProxyStatus, DetectionMethod } from './proxy-detector';
export { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
// Startup lock (prevents race conditions between CCS processes)
export type { LockResult } from './startup-lock';
export { acquireStartupLock, withStartupLock } from './startup-lock';
+220
View File
@@ -0,0 +1,220 @@
/**
* Unified Proxy Detector
*
* Single source of truth for CLIProxy detection.
* Uses multiple detection methods with fallbacks:
* 1. HTTP health check (most reliable, ~1s timeout)
* 2. Session lock file (sessions.json)
* 3. Port process detection (fallback for orphaned proxies)
*
* Detection Order Rationale:
* - HTTP first: Fastest verification that proxy is responsive
* - Session lock second: Catches proxies that are starting up
* - Port process third: Handles orphaned proxies and Windows PID-XXXXX case
*
* Solves race conditions between cliproxy-executor.ts and service-manager.ts
*/
import { getExistingProxy, registerSession } from './session-tracker';
import { isCliproxyRunning } from './stats-fetcher';
import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils';
/** Detection method used to find the proxy */
export type DetectionMethod = 'http' | 'session-lock' | 'port-process' | 'http-retry';
/** Proxy detection status */
export interface ProxyStatus {
/** Whether a proxy is running on the port */
running: boolean;
/** Whether the proxy was verified healthy via HTTP */
verified: boolean;
/** How the proxy was detected */
method?: DetectionMethod;
/** PID of the running proxy (if known) */
pid?: number;
/** Whether port is blocked by non-CLIProxy process */
blocked?: boolean;
/** Process blocking the port (if blocked) */
blocker?: PortProcess;
/** Number of active sessions (if session-lock found) */
sessionCount?: number;
}
/** Optional logger function for verbose output */
type LogFn = (msg: string) => void;
/** No-op logger for when verbose is disabled */
const noopLog: LogFn = () => {};
/**
* Detect running CLIProxy using multiple methods with fallbacks.
*
* Detection order (most reliable first):
* 1. HTTP health check - fastest, verifies proxy is responsive
* 2. Session lock - checks sessions.json for tracked proxy
* 3. Port process - checks OS-level port ownership
*
* @param port Port to check (default: 8317)
* @param verbose Enable verbose logging (default: false)
* @returns ProxyStatus with detection details
*/
export async function detectRunningProxy(
port: number,
verbose: boolean = false
): Promise<ProxyStatus> {
const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog;
log(`Detecting proxy on port ${port}...`);
// 1. First: HTTP health check (fastest, most reliable)
log('Trying HTTP health check...');
const healthy = await isCliproxyRunning(port);
if (healthy) {
// Proxy is running and responsive
// Try to get PID from session lock if available
const lock = getExistingProxy(port);
log(`HTTP check passed, proxy healthy (PID: ${lock?.pid ?? 'unknown'})`);
return {
running: true,
verified: true,
method: 'http',
pid: lock?.pid,
sessionCount: lock?.sessions?.length,
};
}
log('HTTP check failed, proxy not responding');
// 2. Second: Check session lock file
log('Checking session lock file...');
const lock = getExistingProxy(port);
if (lock) {
// Session lock exists - proxy might be starting up
// The lock validates PID is running, so proxy exists but not ready
log(`Session lock found: PID ${lock.pid}, ${lock.sessions.length} sessions`);
return {
running: true,
verified: false,
method: 'session-lock',
pid: lock.pid,
sessionCount: lock.sessions.length,
};
}
log('No session lock found');
// 3. Third: Check port process (fallback for orphaned/untracked proxy)
log('Checking port process...');
const portProcess = await getPortProcess(port);
if (portProcess) {
log(`Port occupied by: ${portProcess.processName} (PID ${portProcess.pid})`);
// Check by process name first (works on Linux/macOS, may fail on Windows)
if (isCLIProxyProcess(portProcess)) {
log('Process name matches CLIProxy, retrying HTTP...');
// Looks like CLIProxy by name - verify with HTTP
const retryHealth = await isCliproxyRunning(port);
if (retryHealth) {
log('HTTP retry passed, proxy is healthy');
return {
running: true,
verified: true,
method: 'http-retry',
pid: portProcess.pid,
};
}
// CLIProxy by name but not responding - might be starting up
log('HTTP retry failed, proxy starting up or unresponsive');
return {
running: true,
verified: false,
method: 'port-process',
pid: portProcess.pid,
};
}
// Process name doesn't match (or Windows returned PID-XXXXX)
// Do one more HTTP check to be sure it's not ours
log('Process name unrecognized, final HTTP check (Windows PID-XXXXX case)...');
const finalHealthCheck = await isCliproxyRunning(port);
if (finalHealthCheck) {
// It IS our proxy, just with unrecognized name (Windows case)
log('Final HTTP check passed, reclaiming proxy with unrecognized name');
return {
running: true,
verified: true,
method: 'http-retry',
pid: portProcess.pid,
};
}
// Definitely not our proxy - port is blocked
log(`Port blocked by non-CLIProxy process: ${portProcess.processName}`);
return {
running: false,
verified: false,
blocked: true,
blocker: portProcess,
};
}
// Port is free
log('Port is free, no proxy detected');
return {
running: false,
verified: false,
};
}
/**
* Wait for proxy to become ready (healthy via HTTP).
* Useful after detecting proxy via session-lock or port-process.
*
* @param port Port to check
* @param timeout Max wait time in ms (default: 5000)
* @param pollInterval Time between checks in ms (default: 100)
* @returns true if proxy became ready, false if timeout
*/
export async function waitForProxyHealthy(
port: number,
timeout: number = 5000,
pollInterval: number = 100
): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeout) {
const healthy = await isCliproxyRunning(port);
if (healthy) {
return true;
}
await new Promise((r) => setTimeout(r, pollInterval));
}
return false;
}
/**
* Attempt to reclaim an orphaned CLIProxy (running but not in session tracker).
* Registers a new session with the detected proxy.
*
* @param port Port where proxy is running
* @param pid PID of the proxy process
* @param verbose Enable verbose logging (default: false)
* @returns Session ID if reclaimed, null if failed
*/
export function reclaimOrphanedProxy(
port: number,
pid: number,
verbose: boolean = false
): string | null {
const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog;
try {
log(`Reclaiming orphaned proxy: port=${port}, pid=${pid}`);
const sessionId = registerSession(port, pid);
log(`Successfully reclaimed proxy, session ID: ${sessionId}`);
return sessionId;
} catch (err) {
const error = err as Error;
log(`Failed to reclaim proxy: ${error.message}`);
return null;
}
}
+127 -99
View File
@@ -21,8 +21,10 @@ import {
CLIPROXY_DEFAULT_PORT,
getCliproxyWritablePath,
} from './config-generator';
import { isCliproxyRunning } from './stats-fetcher';
import { registerSession } from './session-tracker';
import { detectRunningProxy, waitForProxyHealthy } from './proxy-detector';
import { withStartupLock } from './startup-lock';
import { isCliproxyRunning } from './stats-fetcher';
/** Background proxy process reference */
let proxyProcess: ChildProcess | null = null;
@@ -116,10 +118,6 @@ export async function ensureCliproxyService(
}
};
// Check if already running (from another process or previous start)
log(`Checking if CLIProxy is running on port ${port}...`);
const running = await isCliproxyRunning(port);
// Check if config needs update (even if running)
let configRegenerated = false;
if (configNeedsRegeneration()) {
@@ -128,106 +126,136 @@ export async function ensureCliproxyService(
configRegenerated = true;
}
if (running) {
log('CLIProxy already running');
if (configRegenerated) {
log('Config was updated - running instance will use new config on next restart');
}
return { started: true, alreadyRunning: true, port, configRegenerated };
}
// Use startup lock to coordinate with other CCS processes (ccs agy, ccs config, etc.)
return await withStartupLock(async () => {
// Use unified detection (HTTP check + session-lock + port-process)
log(`Checking if CLIProxy is running on port ${port}...`);
const proxyStatus = await detectRunningProxy(port);
log(`Proxy detection: ${JSON.stringify(proxyStatus)}`);
// Need to start new instance
log('CLIProxy not running, starting background instance...');
// 1. Ensure binary exists
let binaryPath: string;
try {
binaryPath = await ensureCLIProxyBinary(verbose);
log(`Binary ready: ${binaryPath}`);
} catch (error) {
const err = error as Error;
return {
started: false,
alreadyRunning: false,
port,
error: `Failed to prepare binary: ${err.message}`,
};
}
// 2. Ensure/regenerate config if needed
let configPath: string;
if (configNeedsRegeneration()) {
log('Config needs regeneration, updating...');
configPath = regenerateConfig(port);
} else {
// generateConfig only creates if doesn't exist
configPath = generateConfig('gemini', port); // Provider doesn't matter for unified config
}
log(`Config ready: ${configPath}`);
// 3. Spawn background process
const proxyArgs = ['--config', configPath];
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
proxyProcess = spawn(binaryPath, proxyArgs, {
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
detached: true, // Allow process to run independently
env: {
...process.env,
WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/
},
});
// Forward output in verbose mode
if (verbose) {
proxyProcess.stdout?.on('data', (data: Buffer) => {
process.stderr.write(`[cliproxy] ${data.toString()}`);
});
proxyProcess.stderr?.on('data', (data: Buffer) => {
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
});
}
// Don't let this process prevent parent from exiting
proxyProcess.unref();
// Handle spawn errors
proxyProcess.on('error', (error) => {
log(`Spawn error: ${error.message}`);
});
// Register cleanup handlers
registerCleanup();
// 4. Wait for proxy to be ready
log(`Waiting for CLIProxy on port ${port}...`);
const ready = await waitForPort(port, 5000);
if (!ready) {
// Kill failed process
if (proxyProcess && !proxyProcess.killed) {
proxyProcess.kill('SIGTERM');
proxyProcess = null;
if (proxyStatus.running && proxyStatus.verified) {
// Already running and healthy
log('CLIProxy already running');
if (configRegenerated) {
log('Config was updated - running instance will use new config on next restart');
}
return { started: true, alreadyRunning: true, port, configRegenerated };
}
return {
started: false,
alreadyRunning: false,
port,
error: `CLIProxy failed to start within 5s on port ${port}`,
};
}
if (proxyStatus.running && !proxyStatus.verified) {
// Proxy detected but not ready yet (another process is starting it)
log(`Proxy starting up (detected via ${proxyStatus.method}), waiting...`);
const becameHealthy = await waitForProxyHealthy(port, 5000);
if (becameHealthy) {
log('Proxy became healthy');
return { started: true, alreadyRunning: true, port, configRegenerated };
}
// Proxy didn't become healthy - will try to start fresh below
log('Proxy detected but not responding, will start fresh');
}
log(`CLIProxy service started on port ${port}`);
if (proxyStatus.blocked) {
// Port blocked by non-CLIProxy process - try HTTP as last resort
const isActuallyOurs = await waitForProxyHealthy(port, 1000);
if (isActuallyOurs) {
log('Reclaimed CLIProxy with unrecognized process name');
return { started: true, alreadyRunning: true, port, configRegenerated };
}
// Truly blocked
return {
started: false,
alreadyRunning: false,
port,
error: `Port ${port} is blocked by ${proxyStatus.blocker?.processName}`,
};
}
// 5. Register session so stopProxy() can find and kill this process
if (proxyProcess.pid) {
registerSession(port, proxyProcess.pid);
log(`Session registered for PID ${proxyProcess.pid}`);
}
// Need to start new instance
log('CLIProxy not running, starting background instance...');
return { started: true, alreadyRunning: false, port };
// 1. Ensure binary exists
let binaryPath: string;
try {
binaryPath = await ensureCLIProxyBinary(verbose);
log(`Binary ready: ${binaryPath}`);
} catch (error) {
const err = error as Error;
return {
started: false,
alreadyRunning: false,
port,
error: `Failed to prepare binary: ${err.message}`,
};
}
// 2. Ensure/regenerate config if needed
let configPath: string;
if (configNeedsRegeneration()) {
log('Config needs regeneration, updating...');
configPath = regenerateConfig(port);
} else {
configPath = generateConfig('gemini', port);
}
log(`Config ready: ${configPath}`);
// 3. Spawn background process
const proxyArgs = ['--config', configPath];
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
proxyProcess = spawn(binaryPath, proxyArgs, {
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
detached: true,
env: {
...process.env,
WRITABLE_PATH: getCliproxyWritablePath(),
},
});
if (verbose) {
proxyProcess.stdout?.on('data', (data: Buffer) => {
process.stderr.write(`[cliproxy] ${data.toString()}`);
});
proxyProcess.stderr?.on('data', (data: Buffer) => {
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
});
}
proxyProcess.unref();
proxyProcess.on('error', (error) => {
log(`Spawn error: ${error.message}`);
});
registerCleanup();
// 4. Wait for proxy to be ready
log(`Waiting for CLIProxy on port ${port}...`);
const ready = await waitForPort(port, 5000);
if (!ready) {
if (proxyProcess && !proxyProcess.killed) {
proxyProcess.kill('SIGTERM');
proxyProcess = null;
}
return {
started: false,
alreadyRunning: false,
port,
error: `CLIProxy failed to start within 5s on port ${port}`,
};
}
log(`CLIProxy service started on port ${port}`);
// 5. Register session
if (proxyProcess.pid) {
registerSession(port, proxyProcess.pid);
log(`Session registered for PID ${proxyProcess.pid}`);
}
return { started: true, alreadyRunning: false, port };
});
}
/**
+228
View File
@@ -0,0 +1,228 @@
/**
* Startup Lock for CLIProxy
*
* File-based mutex to prevent race conditions when multiple
* CCS processes try to start CLIProxy simultaneously.
*
* Uses a lock file with PID and timestamp to coordinate startup.
* Lock is automatically released after timeout or process exit.
*
* Lock Timeout Rationale (10 seconds):
* - CLIProxy startup typically takes 1-3s (binary spawn + port bind)
* - HTTP health check takes ~1s timeout
* - Session registration takes <100ms
* - Total expected lock hold time: 2-5s
* - 10s provides 2x safety margin for slow systems/disk I/O
* - Too short: legitimate startups fail on slow systems
* - Too long: dead processes block other terminals unnecessarily
*
* Why file-based instead of port-based:
* - Works before port is bound (prevents duplicate spawn attempts)
* - Survives process crashes (stale detection via PID check)
* - Cross-platform (Windows, macOS, Linux)
*/
import * as fs from 'fs';
import * as path from 'path';
import { getCliproxyDir } from './config-generator';
/** Lock file structure */
interface LockData {
pid: number;
timestamp: number;
hostname: string;
}
/** Lock acquisition result */
export interface LockResult {
acquired: boolean;
lockPath: string;
release: () => void;
}
/** Lock file name */
const LOCK_FILE = '.startup.lock';
/** Lock timeout in ms (stale lock auto-released) */
const LOCK_TIMEOUT_MS = 10000; // 10 seconds - see module docstring for rationale
/** Optional logger function for verbose output */
type LogFn = (msg: string) => void;
/** No-op logger for when verbose is disabled */
const noopLog: LogFn = () => {};
/**
* Get path to startup lock file
*/
function getLockPath(): string {
return path.join(getCliproxyDir(), LOCK_FILE);
}
/**
* Check if a lock is stale (old or from dead process)
*/
function isLockStale(lockData: LockData): boolean {
// Check timestamp
if (Date.now() - lockData.timestamp > LOCK_TIMEOUT_MS) {
return true;
}
// Check if PID is still running
try {
process.kill(lockData.pid, 0);
return false; // Process exists
} catch {
return true; // Process dead
}
}
/**
* Try to acquire the startup lock once.
*
* @param log Logger function for verbose output
* @returns LockResult with acquired=true if lock obtained
*/
function tryAcquireLockOnce(log: LogFn): LockResult {
const lockPath = getLockPath();
const dir = path.dirname(lockPath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
log(`Creating lock directory: ${dir}`);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Check for existing lock
if (fs.existsSync(lockPath)) {
try {
const content = fs.readFileSync(lockPath, 'utf-8');
const lockData = JSON.parse(content) as LockData;
if (!isLockStale(lockData)) {
// Lock is held by another active process
log(`Lock held by PID ${lockData.pid} (age: ${Date.now() - lockData.timestamp}ms)`);
return {
acquired: false,
lockPath,
release: () => {},
};
}
// Lock is stale - remove and continue
log(`Removing stale lock from PID ${lockData.pid}`);
} catch {
// Invalid lock file - remove and continue
log('Removing invalid lock file');
}
try {
fs.unlinkSync(lockPath);
} catch {
// Ignore removal errors
}
}
// Try to create lock atomically
const lockData: LockData = {
pid: process.pid,
timestamp: Date.now(),
hostname: require('os').hostname(),
};
try {
// Use 'wx' flag for exclusive creation (fails if exists)
fs.writeFileSync(lockPath, JSON.stringify(lockData), { flag: 'wx', mode: 0o600 });
log(`Lock acquired by PID ${process.pid}`);
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'EEXIST') {
// Another process created lock between our check and write
log('Lock acquisition race - another process won');
return {
acquired: false,
lockPath,
release: () => {},
};
}
throw error;
}
// Lock acquired - return release function
const release = () => {
try {
// Only release if we still own it
const content = fs.readFileSync(lockPath, 'utf-8');
const currentLock = JSON.parse(content) as LockData;
if (currentLock.pid === process.pid) {
fs.unlinkSync(lockPath);
log('Lock released');
}
} catch {
// Ignore release errors
}
};
return {
acquired: true,
lockPath,
release,
};
}
/**
* Acquire the startup lock with retries.
*
* @param options.retries Number of retry attempts (default: 20)
* @param options.retryInterval Ms between retries (default: 250)
* @param options.verbose Enable verbose logging (default: false)
* @returns LockResult
* @throws Error if lock cannot be acquired after all retries
*/
export async function acquireStartupLock(options?: {
retries?: number;
retryInterval?: number;
verbose?: boolean;
}): Promise<LockResult> {
const retries = options?.retries ?? 20;
const retryInterval = options?.retryInterval ?? 250;
const log: LogFn = options?.verbose ? (msg) => console.error(`[startup-lock] ${msg}`) : noopLog;
log(`Attempting to acquire startup lock (max ${retries} retries, ${retryInterval}ms interval)`);
for (let attempt = 0; attempt <= retries; attempt++) {
const result = tryAcquireLockOnce(log);
if (result.acquired) {
return result;
}
if (attempt < retries) {
log(`Retry ${attempt + 1}/${retries} in ${retryInterval}ms...`);
await new Promise((r) => setTimeout(r, retryInterval));
}
}
log(`Failed to acquire lock after ${retries} attempts`);
throw new Error(
`Failed to acquire startup lock after ${retries} attempts. ` +
`Another CCS process may be starting CLIProxy.`
);
}
/**
* Execute a function while holding the startup lock.
* Lock is automatically released after function completes or throws.
*
* @param fn Function to execute
* @param options Lock acquisition options (retries, retryInterval, verbose)
* @returns Result of fn
*/
export async function withStartupLock<T>(
fn: () => Promise<T>,
options?: { retries?: number; retryInterval?: number; verbose?: boolean }
): Promise<T> {
const lock = await acquireStartupLock(options);
try {
return await fn();
} finally {
lock.release();
}
}