mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +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:
1 parent
4db2a4915e
commit
96f17e3afb
5 files changed
+650
-159
No files matched your search
@@ -43,19 +43,14 @@ import {
|
|||||||
getDefaultAccount,
|
getDefaultAccount,
|
||||||
} from './account-manager';
|
} from './account-manager';
|
||||||
import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands';
|
import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands';
|
||||||
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
|
||||||
import {
|
import {
|
||||||
ensureMcpWebSearch,
|
ensureMcpWebSearch,
|
||||||
installWebSearchHook,
|
installWebSearchHook,
|
||||||
displayWebSearchStatus,
|
displayWebSearchStatus,
|
||||||
} from '../utils/websearch-manager';
|
} from '../utils/websearch-manager';
|
||||||
import {
|
import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker';
|
||||||
getExistingProxy,
|
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
|
||||||
registerSession,
|
import { withStartupLock } from './startup-lock';
|
||||||
unregisterSession,
|
|
||||||
hasActiveSessions,
|
|
||||||
cleanupOrphanedSessions,
|
|
||||||
} from './session-tracker';
|
|
||||||
|
|
||||||
/** Default executor configuration */
|
/** Default executor configuration */
|
||||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||||
@@ -397,76 +392,88 @@ export async function execClaudeWithCLIProxy(
|
|||||||
const configPath = generateConfig(provider, cfg.port);
|
const configPath = generateConfig(provider, cfg.port);
|
||||||
log(`Config written: ${configPath}`);
|
log(`Config written: ${configPath}`);
|
||||||
|
|
||||||
// 6a. Pre-flight check: handle existing proxy or port conflicts
|
// 6a. Pre-flight check using unified detection with startup lock
|
||||||
// Clean up orphaned sessions first (from crashed proxies)
|
// This prevents race conditions when multiple CCS processes start simultaneously
|
||||||
cleanupOrphanedSessions(cfg.port);
|
cleanupOrphanedSessions(cfg.port);
|
||||||
|
|
||||||
// Check if there's an existing healthy proxy we can reuse
|
// Use startup lock to coordinate with other CCS processes
|
||||||
const existingProxy = getExistingProxy(cfg.port);
|
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) {
|
if (proxyStatus.running && proxyStatus.verified) {
|
||||||
// Reuse existing proxy - another CCS session started it
|
// Healthy proxy found - join it
|
||||||
log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`);
|
if (proxyStatus.pid) {
|
||||||
sessionId = registerSession(cfg.port, existingProxy.pid);
|
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined;
|
||||||
console.log(
|
}
|
||||||
info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
|
if (sessionId) {
|
||||||
);
|
console.log(info(`Joined existing CLIProxy on port ${cfg.port} (${proxyStatus.method})`));
|
||||||
} 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...`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Non-CLIProxy process blocking the port - warn user
|
// Failed to register session, but proxy is running - continue anyway
|
||||||
console.error('');
|
log(`Failed to register session but proxy is healthy, continuing...`);
|
||||||
console.error(
|
}
|
||||||
warn(
|
return; // Exit lock early, skip spawning
|
||||||
`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`
|
}
|
||||||
)
|
|
||||||
);
|
if (proxyStatus.running && !proxyStatus.verified) {
|
||||||
console.error('');
|
// Proxy detected but not ready yet (another process is starting it)
|
||||||
console.error('To fix this, close the blocking application or run:');
|
log(`Proxy starting up (detected via ${proxyStatus.method}), waiting...`);
|
||||||
console.error(` ${getPortCheckCommand(cfg.port)}`);
|
const becameHealthy = await waitForProxyHealthy(cfg.port, cfg.timeout);
|
||||||
console.error('');
|
if (becameHealthy) {
|
||||||
throw new Error(`Port ${cfg.port} is in use by another application`);
|
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)
|
if (proxyStatus.blocked && proxyStatus.blocker) {
|
||||||
// Use detached mode so proxy persists after terminal closes
|
// Port blocked by non-CLIProxy process
|
||||||
const configPath = generateConfig(provider, cfg.port);
|
// Last resort: try HTTP health check (handles Windows PID-XXXXX case)
|
||||||
const proxyArgs = ['--config', configPath];
|
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(' ')}`);
|
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||||
|
|
||||||
proxy = spawn(binaryPath as string, proxyArgs, {
|
proxy = spawn(binaryPath as string, proxyArgs, {
|
||||||
stdio: ['ignore', 'ignore', 'ignore'],
|
stdio: ['ignore', 'ignore', 'ignore'],
|
||||||
detached: true, // Persist after parent terminal closes
|
detached: true,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/
|
WRITABLE_PATH: getCliproxyWritablePath(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unref so parent process can exit independently
|
|
||||||
proxy.unref();
|
proxy.unref();
|
||||||
|
|
||||||
// Handle proxy errors (only fires if spawn itself fails)
|
|
||||||
proxy.on('error', (error) => {
|
proxy.on('error', (error) => {
|
||||||
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
||||||
});
|
});
|
||||||
@@ -504,7 +511,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
// Register this session with the new proxy
|
// Register this session with the new proxy
|
||||||
sessionId = registerSession(cfg.port, proxy.pid as number);
|
sessionId = registerSession(cfg.port, proxy.pid as number);
|
||||||
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`);
|
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Execute Claude CLI with proxied environment
|
// 7. Execute Claude CLI with proxied environment
|
||||||
|
|||||||
@@ -126,3 +126,11 @@ export {
|
|||||||
// Service manager (background CLIProxy for dashboard)
|
// Service manager (background CLIProxy for dashboard)
|
||||||
export type { ServiceStartResult } from './service-manager';
|
export type { ServiceStartResult } from './service-manager';
|
||||||
export { ensureCliproxyService, stopCliproxyService, getServiceStatus } 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,
|
CLIPROXY_DEFAULT_PORT,
|
||||||
getCliproxyWritablePath,
|
getCliproxyWritablePath,
|
||||||
} from './config-generator';
|
} from './config-generator';
|
||||||
import { isCliproxyRunning } from './stats-fetcher';
|
|
||||||
import { registerSession } from './session-tracker';
|
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 */
|
/** Background proxy process reference */
|
||||||
let proxyProcess: ChildProcess | null = null;
|
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)
|
// Check if config needs update (even if running)
|
||||||
let configRegenerated = false;
|
let configRegenerated = false;
|
||||||
if (configNeedsRegeneration()) {
|
if (configNeedsRegeneration()) {
|
||||||
@@ -128,106 +126,136 @@ export async function ensureCliproxyService(
|
|||||||
configRegenerated = true;
|
configRegenerated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (running) {
|
// Use startup lock to coordinate with other CCS processes (ccs agy, ccs config, etc.)
|
||||||
log('CLIProxy already running');
|
return await withStartupLock(async () => {
|
||||||
if (configRegenerated) {
|
// Use unified detection (HTTP check + session-lock + port-process)
|
||||||
log('Config was updated - running instance will use new config on next restart');
|
log(`Checking if CLIProxy is running on port ${port}...`);
|
||||||
}
|
const proxyStatus = await detectRunningProxy(port);
|
||||||
return { started: true, alreadyRunning: true, port, configRegenerated };
|
log(`Proxy detection: ${JSON.stringify(proxyStatus)}`);
|
||||||
}
|
|
||||||
|
|
||||||
// Need to start new instance
|
if (proxyStatus.running && proxyStatus.verified) {
|
||||||
log('CLIProxy not running, starting background instance...');
|
// Already running and healthy
|
||||||
|
log('CLIProxy already running');
|
||||||
// 1. Ensure binary exists
|
if (configRegenerated) {
|
||||||
let binaryPath: string;
|
log('Config was updated - running instance will use new config on next restart');
|
||||||
try {
|
}
|
||||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
return { started: true, alreadyRunning: true, port, configRegenerated };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (proxyStatus.running && !proxyStatus.verified) {
|
||||||
started: false,
|
// Proxy detected but not ready yet (another process is starting it)
|
||||||
alreadyRunning: false,
|
log(`Proxy starting up (detected via ${proxyStatus.method}), waiting...`);
|
||||||
port,
|
const becameHealthy = await waitForProxyHealthy(port, 5000);
|
||||||
error: `CLIProxy failed to start within 5s on port ${port}`,
|
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
|
// Need to start new instance
|
||||||
if (proxyProcess.pid) {
|
log('CLIProxy not running, starting background instance...');
|
||||||
registerSession(port, proxyProcess.pid);
|
|
||||||
log(`Session registered for PID ${proxyProcess.pid}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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