mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 08:17:22 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user