Merge pull request #210 from kaitranntt/fix/cliproxy-version-sync-restart

fix(cliproxy): ensure version sync after binary update
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2025-12-26 10:41:11 -08:00
commit 7d1525b75c
5 files changed
+174 -12

No files matched your search

+24 -2
View File
@@ -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<void> {
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 */
+38 -6
View File
@@ -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})`
);
});
}
+15 -3
View File
@@ -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');
+71 -1
View File
@@ -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<boolean> {
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;
}
+26
View File
@@ -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<boolean> {
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