From 96f17e3afba93288b041f6c2883753692b4e9ca1 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 10:50:25 -0500 Subject: [PATCH] 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. --- src/cliproxy/cliproxy-executor.ts | 127 +++++++++-------- src/cliproxy/index.ts | 8 ++ src/cliproxy/proxy-detector.ts | 220 ++++++++++++++++++++++++++++ src/cliproxy/service-manager.ts | 226 ++++++++++++++++------------- src/cliproxy/startup-lock.ts | 228 ++++++++++++++++++++++++++++++ 5 files changed, 650 insertions(+), 159 deletions(-) create mode 100644 src/cliproxy/proxy-detector.ts create mode 100644 src/cliproxy/startup-lock.ts diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 5a162743..dd72b261 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -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 diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index ecf890b1..e42705be 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -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'; diff --git a/src/cliproxy/proxy-detector.ts b/src/cliproxy/proxy-detector.ts new file mode 100644 index 00000000..b2afe545 --- /dev/null +++ b/src/cliproxy/proxy-detector.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index 10992fda..1e814fbe 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -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 }; + }); } /** diff --git a/src/cliproxy/startup-lock.ts b/src/cliproxy/startup-lock.ts new file mode 100644 index 00000000..8d63e3d0 --- /dev/null +++ b/src/cliproxy/startup-lock.ts @@ -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 { + 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( + fn: () => Promise, + options?: { retries?: number; retryInterval?: number; verbose?: boolean } +): Promise { + const lock = await acquireStartupLock(options); + try { + return await fn(); + } finally { + lock.release(); + } +}