diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 4e23f880..51a33d45 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -5,10 +5,12 @@ * Pattern: Mirrors npm install behavior (fast check, download only when needed) */ -import { info } from '../utils/ui'; -import { getBinDir } from './config-generator'; +import { info, warn } from '../utils/ui'; +import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; import { BinaryInfo, BinaryManagerConfig } from './types'; import { CLIPROXY_FALLBACK_VERSION } from './platform-detector'; +import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service'; +import { waitForPortFree } from '../utils/port-utils'; import { UpdateCheckResult, checkForUpdates, @@ -108,12 +110,32 @@ export function getInstalledCliproxyVersion(): string { /** Install a specific version of CLIProxyAPI */ export async function installCliproxyVersion(version: string, verbose = false): Promise { const manager = new BinaryManager({ version, verbose, forceVersion: true }); + + // Check if proxy is running and stop it first + if (isProxyRunning()) { + if (verbose) console.log(info('Stopping running CLIProxy before update...')); + const result = await stopProxy(); + if (result.stopped) { + // Wait for port to be fully released + const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000); + if (!portFree && verbose) { + console.log(warn('Port did not free up in time, proceeding anyway...')); + } + } else if (verbose && result.error) { + console.log(warn(`Could not stop proxy: ${result.error}`)); + } + } + if (manager.isBinaryInstalled()) { if (verbose) console.log(info(`Removing existing CLIProxy Plus v${getInstalledCliproxyVersion()}`)); manager.deleteBinary(); } await manager.ensureBinary(); + + if (verbose) { + console.log(info('New version will be active on next CLIProxy command')); + } } /** Fetch the latest CLIProxyAPI version from GitHub API */ diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 17ce150f..c2813dce 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -17,7 +17,7 @@ import * as net from 'net'; import { ProgressIndicator } from '../utils/progress-indicator'; import { ok, fail, info, warn } from '../utils/ui'; import { escapeShellArg } from '../utils/shell-executor'; -import { ensureCLIProxyBinary } from './binary-manager'; +import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from './binary-manager'; import { generateConfig, getEffectiveEnvVars, @@ -48,7 +48,12 @@ import { installWebSearchHook, displayWebSearchStatus, } from '../utils/websearch-manager'; -import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker'; +import { + registerSession, + unregisterSession, + cleanupOrphanedSessions, + stopProxy, +} from './session-tracker'; import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; @@ -429,9 +434,33 @@ export async function execClaudeWithCLIProxy( // 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); + let proxyStatus = await detectRunningProxy(cfg.port); log(`Proxy detection: ${JSON.stringify(proxyStatus)}`); + // Check for version mismatch - restart proxy if installed version differs from running + if (proxyStatus.running && proxyStatus.verified && proxyStatus.version) { + const installedVersion = getInstalledCliproxyVersion(); + if (installedVersion !== proxyStatus.version) { + console.log( + warn( + `Version mismatch: running v${proxyStatus.version}, installed v${installedVersion}. Restarting proxy...` + ) + ); + log(`Stopping outdated proxy (PID: ${proxyStatus.pid ?? 'unknown'})...`); + const stopResult = await stopProxy(cfg.port); + if (stopResult.stopped) { + log(`Stopped outdated proxy successfully`); + } else { + log(`Stop proxy result: ${stopResult.error ?? 'unknown error'}`); + } + // Wait for port to be released + await new Promise((r) => setTimeout(r, 500)); + // Re-detect proxy status (should now be not running) + proxyStatus = await detectRunningProxy(cfg.port); + log(`Re-detection after version mismatch restart: ${JSON.stringify(proxyStatus)}`); + } + } + if (proxyStatus.running && proxyStatus.verified) { // Healthy proxy found - join it if (proxyStatus.pid) { @@ -542,9 +571,12 @@ export async function execClaudeWithCLIProxy( throw new Error(`CLIProxy startup failed: ${err.message}`); } - // 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})`); + // Register this session with the new proxy, including the installed version + const installedVersion = getInstalledCliproxyVersion(); + sessionId = registerSession(cfg.port, proxy.pid as number, installedVersion); + log( + `Registered session ${sessionId} with new proxy (PID ${proxy.pid}, version ${installedVersion})` + ); }); } diff --git a/src/cliproxy/proxy-detector.ts b/src/cliproxy/proxy-detector.ts index 1a7bc812..d1d6afe6 100644 --- a/src/cliproxy/proxy-detector.ts +++ b/src/cliproxy/proxy-detector.ts @@ -15,7 +15,7 @@ * Solves race conditions between cliproxy-executor.ts and service-manager.ts */ -import { getExistingProxy, registerSession } from './session-tracker'; +import { getExistingProxy, registerSession, getRunningProxyVersion } from './session-tracker'; import { isCliproxyRunning } from './stats-fetcher'; import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils'; @@ -38,6 +38,8 @@ export interface ProxyStatus { blocker?: PortProcess; /** Number of active sessions (if session-lock found) */ sessionCount?: number; + /** Version of the running proxy (from session lock) */ + version?: string; } /** Optional logger function for verbose output */ @@ -86,13 +88,19 @@ export async function detectRunningProxy( } } - log(`HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'})`); + // Get version from session lock + const runningVersion = getRunningProxyVersion(port); + + log( + `HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'}, version: ${runningVersion ?? 'unknown'})` + ); return { running: true, verified: true, method: 'http', pid, sessionCount: lock?.sessions?.length, + version: runningVersion ?? undefined, }; } log('HTTP check failed, proxy not responding'); @@ -103,13 +111,17 @@ export async function detectRunningProxy( 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`); + const lockVersion = getRunningProxyVersion(port); + log( + `Session lock found: PID ${lock.pid}, ${lock.sessions.length} sessions, version: ${lockVersion ?? 'unknown'}` + ); return { running: true, verified: false, method: 'session-lock', pid: lock.pid, sessionCount: lock.sessions.length, + version: lockVersion ?? undefined, }; } log('No session lock found'); diff --git a/src/cliproxy/session-tracker.ts b/src/cliproxy/session-tracker.ts index 05d5eee8..2279eeba 100644 --- a/src/cliproxy/session-tracker.ts +++ b/src/cliproxy/session-tracker.ts @@ -27,6 +27,8 @@ interface SessionLock { pid: number; sessions: string[]; startedAt: string; + /** CLIProxy version running (added for version mismatch detection) */ + version?: string; } /** Generate unique session ID */ @@ -125,6 +127,23 @@ function isProcessRunning(pid: number): boolean { } } +/** + * Wait for a process to exit within a timeout. + * @param pid Process ID to wait for + * @param timeoutMs Maximum time to wait in milliseconds + * @returns true if process exited, false if timeout + */ +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (!isProcessRunning(pid)) { + return true; // Process exited + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; // Timeout +} + /** * Check if there's an existing proxy running that we can reuse. * Returns the existing lock if proxy is healthy, null otherwise. @@ -153,9 +172,12 @@ export function getExistingProxy(port: number): SessionLock | null { /** * Register a new session with the proxy. * Call this when starting a new CCS session that will use an existing proxy. + * @param port Port the proxy is running on + * @param proxyPid PID of the proxy process + * @param version Optional CLIProxy version (stored when spawning new proxy) * @returns Session ID for this session */ -export function registerSession(port: number, proxyPid: number): string { +export function registerSession(port: number, proxyPid: number, version?: string): string { const sessionId = generateSessionId(); const existingLock = readSessionLockForPort(port); @@ -170,6 +192,7 @@ export function registerSession(port: number, proxyPid: number): string { pid: proxyPid, sessions: [sessionId], startedAt: new Date().toISOString(), + version, }; writeSessionLockForPort(newLock); } @@ -315,6 +338,19 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{ // Found CLIProxy running without session lock - kill it try { process.kill(portProcess.pid, 'SIGTERM'); + + // Wait for graceful shutdown + const exited = await waitForProcessExit(portProcess.pid, 3000); + if (!exited) { + // Escalate to SIGKILL + try { + process.kill(portProcess.pid, 'SIGKILL'); + await waitForProcessExit(portProcess.pid, 1000); + } catch { + // Process may have exited between check and kill + } + } + return { stopped: true, pid: portProcess.pid, sessionCount: 0 }; } catch (err) { const error = err as NodeJS.ErrnoException; @@ -338,6 +374,18 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{ // Kill the proxy process process.kill(pid, 'SIGTERM'); + // Wait for graceful shutdown + const exited = await waitForProcessExit(pid, 3000); + if (!exited) { + // Escalate to SIGKILL + try { + process.kill(pid, 'SIGKILL'); + await waitForProcessExit(pid, 1000); + } catch { + // Process may have exited between check and kill + } + } + // Clean up session lock deleteSessionLockForPort(port); @@ -362,6 +410,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): { pid?: number; sessionCount?: number; startedAt?: string; + version?: string; } { const lock = readSessionLockForPort(port); @@ -381,5 +430,26 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): { pid: lock.pid, sessionCount: lock.sessions.length, startedAt: lock.startedAt, + version: lock.version, }; } + +/** + * Get the version of the running proxy from session lock. + * @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT) + * @returns Version string if available, null otherwise + */ +export function getRunningProxyVersion(port: number = CLIPROXY_DEFAULT_PORT): string | null { + const lock = readSessionLockForPort(port); + if (!lock) { + return null; + } + + // Verify proxy is still running + if (!isProcessRunning(lock.pid)) { + deleteSessionLockForPort(port); + return null; + } + + return lock.version ?? null; +} diff --git a/src/utils/port-utils.ts b/src/utils/port-utils.ts index 2359f1ab..31f87208 100644 --- a/src/utils/port-utils.ts +++ b/src/utils/port-utils.ts @@ -181,6 +181,32 @@ export interface BindingTestResult { message: string; } +/** + * Wait for a port to become free (no process listening) + * @param port Port number to wait for + * @param timeoutMs Maximum time to wait in milliseconds (default: 5000) + * @param pollIntervalMs Interval between checks in milliseconds (default: 200) + * @returns True if port became free, false if timeout + */ +export async function waitForPortFree( + port: number, + timeoutMs = 5000, + pollIntervalMs = 200 +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const process = await getPortProcess(port); + if (!process) { + return true; // Port is free + } + // Wait before next check + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return false; // Timeout reached +} + /** * Test if we can bind to localhost on a specific port * This verifies network stack is working and port is actually available