Files
ccs/src/cliproxy/proxy-detector.ts
T
kaitranntt e0a1f8f312 fix(cliproxy): add comprehensive port validation across proxy system
- Export validatePort() from config-generator with full edge case handling
- Add YAML port validation in proxy-config-resolver before use
- Add final port validation in cliproxy-executor after config merge
- Add default port parameter and validation to proxy-detector
- Add 38 new tests covering all port validation edge cases

Fixes undefined port bug when upgrading from 7.11.1 to 7.12.0
2026-01-01 02:00:30 -05:00

251 lines
8.1 KiB
TypeScript

/**
* 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, getRunningProxyVersion } from './session-tracker';
import { isCliproxyRunning } from './stats-fetcher';
import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils';
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
/** 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;
/** Version of the running proxy (from session lock) */
version?: string;
}
/** 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 = CLIPROXY_DEFAULT_PORT,
verbose: boolean = false
): Promise<ProxyStatus> {
const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog;
// Validate port - fallback to default if invalid
const validPort =
port && Number.isFinite(port) && port >= 1 && port <= 65535 ? port : CLIPROXY_DEFAULT_PORT;
log(`Detecting proxy on port ${validPort}...`);
// 1. First: HTTP health check (fastest, most reliable)
log('Trying HTTP health check...');
const healthy = await isCliproxyRunning(validPort);
if (healthy) {
// Proxy is running and responsive
// Try to get PID from session lock if available
const lock = getExistingProxy(validPort);
let pid = lock?.pid;
// If no PID from session lock, try port-process detection
// This handles orphaned proxies (running but no sessions.json)
if (!pid) {
log('No PID from session lock, checking port process...');
const portProcess = await getPortProcess(validPort);
if (portProcess) {
pid = portProcess.pid;
log(`Got PID from port process: ${pid}`);
}
}
// Get version from session lock
const runningVersion = getRunningProxyVersion(validPort);
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');
// 2. Second: Check session lock file
log('Checking session lock file...');
const lock = getExistingProxy(validPort);
if (lock) {
// Session lock exists - proxy might be starting up
// The lock validates PID is running, so proxy exists but not ready
const lockVersion = getRunningProxyVersion(validPort);
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');
// 3. Third: Check port process (fallback for orphaned/untracked proxy)
log('Checking port process...');
const portProcess = await getPortProcess(validPort);
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(validPort);
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(validPort);
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;
}
}