mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
- Add backend field to BinaryManagerConfig type for installer context - Thread backend parameter through tar/zip extractors to use correct names - Delete existing binary before install to prevent mismatched binaries - Track backend in session metadata for debugging/monitoring - Validate and preserve backend field in config mergeWithDefaults - Pass backend to registerSession for session tracking The core issue was extractors calling getExecutableName() without the backend parameter, causing it to default to 'plus' regardless of user selection. This resulted in wrong binaries being extracted/renamed.
465 lines
13 KiB
TypeScript
465 lines
13 KiB
TypeScript
/**
|
|
* Session Tracker for CLIProxy Multi-Instance Support
|
|
*
|
|
* Manages reference counting for shared CLIProxy instances.
|
|
* Multiple CCS sessions can share a single proxy on the same port.
|
|
* Proxy only terminates when ALL sessions exit (count reaches 0).
|
|
*
|
|
* Lock file format: ~/.ccs/cliproxy/sessions.json
|
|
* {
|
|
* "port": 8317,
|
|
* "pid": 12345, // CLIProxy process PID
|
|
* "sessions": ["abc123", "def456"], // Active session IDs
|
|
* "startedAt": "2024-01-01T00:00:00Z"
|
|
* }
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as crypto from 'crypto';
|
|
import { getCliproxyDir } from './config-generator';
|
|
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
|
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
|
|
|
|
/** Session lock file structure */
|
|
interface SessionLock {
|
|
port: number;
|
|
pid: number;
|
|
sessions: string[];
|
|
startedAt: string;
|
|
/** CLIProxy version running (added for version mismatch detection) */
|
|
version?: string;
|
|
/** Backend type running (original vs plus) */
|
|
backend?: 'original' | 'plus';
|
|
}
|
|
|
|
/** Generate unique session ID */
|
|
function generateSessionId(): string {
|
|
return crypto.randomBytes(8).toString('hex');
|
|
}
|
|
|
|
/** Get path to session lock file for specific port */
|
|
function getSessionLockPathForPort(port: number): string {
|
|
if (port === CLIPROXY_DEFAULT_PORT) {
|
|
return path.join(getCliproxyDir(), 'sessions.json');
|
|
}
|
|
return path.join(getCliproxyDir(), `sessions-${port}.json`);
|
|
}
|
|
|
|
/** Get path to session lock file (default port) - kept for future use */
|
|
function _getSessionLockPath(): string {
|
|
return getSessionLockPathForPort(CLIPROXY_DEFAULT_PORT);
|
|
}
|
|
|
|
// Re-export for external use
|
|
export { _getSessionLockPath as getSessionLockPath };
|
|
|
|
// Export deleteSessionLockForPort for cleanup operations
|
|
export { deleteSessionLockForPort };
|
|
|
|
/** Read session lock file for specific port (returns null if not exists or invalid) */
|
|
function readSessionLockForPort(port: number): SessionLock | null {
|
|
const lockPath = getSessionLockPathForPort(port);
|
|
try {
|
|
if (!fs.existsSync(lockPath)) {
|
|
return null;
|
|
}
|
|
const content = fs.readFileSync(lockPath, 'utf-8');
|
|
const lock = JSON.parse(content) as SessionLock;
|
|
// Validate structure
|
|
if (
|
|
typeof lock.port !== 'number' ||
|
|
typeof lock.pid !== 'number' ||
|
|
!Array.isArray(lock.sessions)
|
|
) {
|
|
return null;
|
|
}
|
|
return lock;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Read session lock file (default port, returns null if not exists or invalid) */
|
|
function readSessionLock(): SessionLock | null {
|
|
return readSessionLockForPort(CLIPROXY_DEFAULT_PORT);
|
|
}
|
|
|
|
/** Write session lock file for specific port */
|
|
function writeSessionLockForPort(lock: SessionLock): void {
|
|
const lockPath = getSessionLockPathForPort(lock.port);
|
|
const dir = path.dirname(lockPath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
}
|
|
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { mode: 0o600 });
|
|
}
|
|
|
|
/** Delete session lock file for specific port */
|
|
function deleteSessionLockForPort(port: number): void {
|
|
const lockPath = getSessionLockPathForPort(port);
|
|
try {
|
|
if (fs.existsSync(lockPath)) {
|
|
fs.unlinkSync(lockPath);
|
|
}
|
|
} catch {
|
|
// Ignore errors on cleanup
|
|
}
|
|
}
|
|
|
|
/** Delete session lock file (default port) */
|
|
function deleteSessionLock(): void {
|
|
deleteSessionLockForPort(CLIPROXY_DEFAULT_PORT);
|
|
}
|
|
|
|
/** Check if a PID is still running */
|
|
function isProcessRunning(pid: number): boolean {
|
|
try {
|
|
// Sending signal 0 checks if process exists without killing it
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (err) {
|
|
const e = err as NodeJS.ErrnoException;
|
|
// EPERM means process exists but we don't have permission to signal it
|
|
if (e.code === 'EPERM') {
|
|
return true;
|
|
}
|
|
// ESRCH means no such process
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function getExistingProxy(port: number): SessionLock | null {
|
|
const lock = readSessionLockForPort(port);
|
|
if (!lock) {
|
|
return null;
|
|
}
|
|
|
|
// Verify port matches
|
|
if (lock.port !== port) {
|
|
return null;
|
|
}
|
|
|
|
// Verify proxy process is still running
|
|
if (!isProcessRunning(lock.pid)) {
|
|
// Proxy crashed - clean up stale lock
|
|
deleteSessionLockForPort(port);
|
|
return null;
|
|
}
|
|
|
|
return lock;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
* @param backend Optional backend type (original vs plus)
|
|
* @returns Session ID for this session
|
|
*/
|
|
export function registerSession(
|
|
port: number,
|
|
proxyPid: number,
|
|
version?: string,
|
|
backend?: 'original' | 'plus'
|
|
): string {
|
|
const sessionId = generateSessionId();
|
|
const existingLock = readSessionLockForPort(port);
|
|
|
|
if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) {
|
|
// Add to existing sessions
|
|
existingLock.sessions.push(sessionId);
|
|
writeSessionLockForPort(existingLock);
|
|
} else {
|
|
// Create new lock (first session for this proxy)
|
|
const newLock: SessionLock = {
|
|
port,
|
|
pid: proxyPid,
|
|
sessions: [sessionId],
|
|
startedAt: new Date().toISOString(),
|
|
version,
|
|
backend,
|
|
};
|
|
writeSessionLockForPort(newLock);
|
|
}
|
|
|
|
return sessionId;
|
|
}
|
|
|
|
/**
|
|
* Unregister a session from the proxy.
|
|
* @param sessionId Session ID to unregister
|
|
* @param port Port to unregister from (optional, searches default port if not provided)
|
|
* @returns true if this was the last session (proxy should be killed)
|
|
*/
|
|
export function unregisterSession(sessionId: string, port?: number): boolean {
|
|
// If port provided, use port-specific lookup
|
|
if (port !== undefined) {
|
|
const lock = readSessionLockForPort(port);
|
|
if (!lock) {
|
|
return true;
|
|
}
|
|
|
|
const index = lock.sessions.indexOf(sessionId);
|
|
if (index !== -1) {
|
|
lock.sessions.splice(index, 1);
|
|
}
|
|
|
|
if (lock.sessions.length === 0) {
|
|
deleteSessionLockForPort(port);
|
|
return true;
|
|
}
|
|
|
|
writeSessionLockForPort(lock);
|
|
return false;
|
|
}
|
|
|
|
// Fallback: search default port (backward compat)
|
|
const lock = readSessionLock();
|
|
if (!lock) {
|
|
// No lock file - assume we're the only session
|
|
return true;
|
|
}
|
|
|
|
// Remove this session from the list
|
|
const index = lock.sessions.indexOf(sessionId);
|
|
if (index !== -1) {
|
|
lock.sessions.splice(index, 1);
|
|
}
|
|
|
|
// Check if any sessions remain
|
|
if (lock.sessions.length === 0) {
|
|
// Last session - clean up lock file
|
|
deleteSessionLock();
|
|
return true;
|
|
}
|
|
|
|
// Other sessions still active - keep proxy running
|
|
writeSessionLockForPort(lock);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get current session count for the proxy.
|
|
* @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT)
|
|
*/
|
|
export function getSessionCount(port: number = CLIPROXY_DEFAULT_PORT): number {
|
|
const lock = readSessionLockForPort(port);
|
|
if (!lock) {
|
|
return 0;
|
|
}
|
|
return lock.sessions.length;
|
|
}
|
|
|
|
/**
|
|
* Check if proxy has any active sessions.
|
|
* Used to determine if a "zombie" proxy should be killed.
|
|
* @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT)
|
|
*/
|
|
export function hasActiveSessions(port: number = CLIPROXY_DEFAULT_PORT): boolean {
|
|
const lock = readSessionLockForPort(port);
|
|
if (!lock) {
|
|
return false;
|
|
}
|
|
|
|
// Verify proxy is still running
|
|
if (!isProcessRunning(lock.pid)) {
|
|
deleteSessionLockForPort(port);
|
|
return false;
|
|
}
|
|
|
|
return lock.sessions.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Clean up orphaned sessions (when proxy crashes).
|
|
* Called on startup to ensure clean state.
|
|
*/
|
|
export function cleanupOrphanedSessions(port: number): void {
|
|
const lock = readSessionLockForPort(port);
|
|
if (!lock) {
|
|
return;
|
|
}
|
|
|
|
// If port doesn't match, this shouldn't happen with port-specific files
|
|
if (lock.port !== port) {
|
|
return;
|
|
}
|
|
|
|
// If proxy is dead, clean up lock
|
|
if (!isProcessRunning(lock.pid)) {
|
|
deleteSessionLockForPort(port);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop the CLIProxy process and clean up session lock.
|
|
* Falls back to port-based detection if no session lock exists.
|
|
* @param port Port to stop (defaults to CLIPROXY_DEFAULT_PORT)
|
|
* @returns Object with success status and details
|
|
*/
|
|
export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
|
stopped: boolean;
|
|
pid?: number;
|
|
sessionCount?: number;
|
|
error?: string;
|
|
}> {
|
|
const lock = readSessionLockForPort(port);
|
|
|
|
if (!lock) {
|
|
// No session lock - try to find process by port (legacy/untracked proxy)
|
|
const portProcess = await getPortProcess(port);
|
|
|
|
if (!portProcess) {
|
|
return { stopped: false, error: 'No active CLIProxy session found' };
|
|
}
|
|
|
|
if (!isCLIProxyProcess(portProcess)) {
|
|
return {
|
|
stopped: false,
|
|
error: `Port ${port} is in use by ${portProcess.processName}, not CLIProxy`,
|
|
};
|
|
}
|
|
|
|
// 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;
|
|
if (error.code === 'ESRCH') {
|
|
return { stopped: false, error: 'CLIProxy process already terminated' };
|
|
}
|
|
return { stopped: false, pid: portProcess.pid, error: `Failed to stop: ${error.message}` };
|
|
}
|
|
}
|
|
|
|
// Check if proxy is running
|
|
if (!isProcessRunning(lock.pid)) {
|
|
deleteSessionLockForPort(port);
|
|
return { stopped: false, error: 'CLIProxy was not running (cleaned up stale lock)' };
|
|
}
|
|
|
|
const sessionCount = lock.sessions.length;
|
|
const pid = lock.pid;
|
|
|
|
try {
|
|
// 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);
|
|
|
|
return { stopped: true, pid, sessionCount };
|
|
} catch (err) {
|
|
const error = err as NodeJS.ErrnoException;
|
|
if (error.code === 'ESRCH') {
|
|
// Process already gone
|
|
deleteSessionLockForPort(port);
|
|
return { stopped: false, error: 'CLIProxy process already terminated' };
|
|
}
|
|
return { stopped: false, pid, error: `Failed to stop: ${error.message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get proxy status information for specific port.
|
|
*/
|
|
export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
|
|
running: boolean;
|
|
port?: number;
|
|
pid?: number;
|
|
sessionCount?: number;
|
|
startedAt?: string;
|
|
version?: string;
|
|
} {
|
|
const lock = readSessionLockForPort(port);
|
|
|
|
if (!lock) {
|
|
return { running: false };
|
|
}
|
|
|
|
// Verify proxy is still running
|
|
if (!isProcessRunning(lock.pid)) {
|
|
deleteSessionLockForPort(port);
|
|
return { running: false };
|
|
}
|
|
|
|
return {
|
|
running: true,
|
|
port: lock.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;
|
|
}
|