fix(cliproxy): ensure version sync after binary update

- Add SIGKILL escalation after 3s SIGTERM timeout in session-tracker
- Auto-stop running proxy before binary update in binary-manager
- Add waitForPortFree utility to port-utils
- Detect version mismatch on startup and auto-restart outdated proxy
- Store and expose version in session lock for detection

Fixes issue where old proxy version continues running after update,
causing UI to show different version than actually running.
This commit is contained in:
kaitranntt
2025-12-26 13:31:27 -05:00
parent 2093ae2cc8
commit 29f19308e6
5 changed files with 174 additions and 12 deletions
+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