mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 06:15:03 +00:00
fix(cliproxy): prevent shared proxy termination on multi-session exit
Add session tracking with reference counting for CLIProxy instances. Multiple CCS sessions now safely share a single proxy on port 8317. When any session exits, proxy only terminates if it was the last session. Changes: - Add session-tracker.ts: lock file based reference counting - Modify cliproxy-executor.ts: reuse existing proxy, conditional cleanup - Add 21 unit tests for session tracker functionality Fixes #118
This commit is contained in:
1 parent
38844540bb
commit
3629e3e2fb
3 files changed
+699
-78
No files matched your search
@@ -45,6 +45,13 @@ import {
|
|||||||
installWebSearchHook,
|
installWebSearchHook,
|
||||||
displayWebSearchStatus,
|
displayWebSearchStatus,
|
||||||
} from '../utils/websearch-manager';
|
} from '../utils/websearch-manager';
|
||||||
|
import {
|
||||||
|
getExistingProxy,
|
||||||
|
registerSession,
|
||||||
|
unregisterSession,
|
||||||
|
hasActiveSessions,
|
||||||
|
cleanupOrphanedSessions,
|
||||||
|
} from './session-tracker';
|
||||||
|
|
||||||
/** Default executor configuration */
|
/** Default executor configuration */
|
||||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||||
@@ -303,85 +310,116 @@ export async function execClaudeWithCLIProxy(
|
|||||||
const configPath = generateConfig(provider, cfg.port);
|
const configPath = generateConfig(provider, cfg.port);
|
||||||
log(`Config written: ${configPath}`);
|
log(`Config written: ${configPath}`);
|
||||||
|
|
||||||
// 6a. Pre-flight check: ensure port is free (auto-kill zombie CLIProxy)
|
// 6a. Pre-flight check: handle existing proxy or port conflicts
|
||||||
const portProcess = await getPortProcess(cfg.port);
|
// Clean up orphaned sessions first (from crashed proxies)
|
||||||
if (portProcess) {
|
cleanupOrphanedSessions(cfg.port);
|
||||||
if (isCLIProxyProcess(portProcess)) {
|
|
||||||
// Zombie CLIProxy from previous run - auto-kill it
|
// Check if there's an existing healthy proxy we can reuse
|
||||||
log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`);
|
const existingProxy = getExistingProxy(cfg.port);
|
||||||
const killed = killProcessOnPort(cfg.port, verbose);
|
let proxy: ChildProcess | null = null;
|
||||||
if (killed) {
|
let sessionId: string;
|
||||||
console.log(info(`Cleaned up zombie CLIProxy process`));
|
let isReusingProxy = false;
|
||||||
// Wait a bit for port to be released
|
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
if (existingProxy) {
|
||||||
|
// Reuse existing proxy - another CCS session started it
|
||||||
|
log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`);
|
||||||
|
sessionId = registerSession(cfg.port, existingProxy.pid);
|
||||||
|
isReusingProxy = true;
|
||||||
|
console.log(
|
||||||
|
info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No existing proxy - check if port is free
|
||||||
|
const portProcess = await getPortProcess(cfg.port);
|
||||||
|
if (portProcess) {
|
||||||
|
if (isCLIProxyProcess(portProcess)) {
|
||||||
|
// CLIProxy on port but no session lock - likely orphaned/zombie
|
||||||
|
// Only kill if no active sessions registered
|
||||||
|
if (!hasActiveSessions()) {
|
||||||
|
log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`);
|
||||||
|
const killed = killProcessOnPort(cfg.port, verbose);
|
||||||
|
if (killed) {
|
||||||
|
console.log(info(`Cleaned up zombie CLIProxy process`));
|
||||||
|
// Wait a bit for port to be released
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Active sessions exist but getExistingProxy returned null - something's wrong
|
||||||
|
// Try to connect anyway
|
||||||
|
log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-CLIProxy process blocking the port - warn user
|
||||||
|
console.error('');
|
||||||
|
console.error(
|
||||||
|
warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`)
|
||||||
|
);
|
||||||
|
console.error('');
|
||||||
|
console.error('To fix this, close the blocking application or run:');
|
||||||
|
console.error(` ${getPortCheckCommand(cfg.port)}`);
|
||||||
|
console.error('');
|
||||||
|
throw new Error(`Port ${cfg.port} is in use by another application`);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Non-CLIProxy process blocking the port - warn user
|
|
||||||
console.error('');
|
|
||||||
console.error(
|
|
||||||
warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`)
|
|
||||||
);
|
|
||||||
console.error('');
|
|
||||||
console.error('To fix this, close the blocking application or run:');
|
|
||||||
console.error(` ${getPortCheckCommand(cfg.port)}`);
|
|
||||||
console.error('');
|
|
||||||
throw new Error(`Port ${cfg.port} is in use by another application`);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 6b. Spawn CLIProxyAPI binary
|
// 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy)
|
||||||
const proxyArgs = ['--config', configPath];
|
const proxyArgs = ['--config', configPath];
|
||||||
|
|
||||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||||
|
|
||||||
const proxy = spawn(binaryPath, proxyArgs, {
|
proxy = spawn(binaryPath, proxyArgs, {
|
||||||
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
|
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
|
||||||
detached: false,
|
detached: false,
|
||||||
});
|
|
||||||
|
|
||||||
// Forward proxy output in verbose mode
|
|
||||||
if (verbose) {
|
|
||||||
proxy.stdout?.on('data', (data: Buffer) => {
|
|
||||||
process.stderr.write(`[cliproxy-out] ${data.toString()}`);
|
|
||||||
});
|
});
|
||||||
proxy.stderr?.on('data', (data: Buffer) => {
|
|
||||||
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
|
// Forward proxy output in verbose mode
|
||||||
|
if (verbose) {
|
||||||
|
proxy.stdout?.on('data', (data: Buffer) => {
|
||||||
|
process.stderr.write(`[cliproxy-out] ${data.toString()}`);
|
||||||
|
});
|
||||||
|
proxy.stderr?.on('data', (data: Buffer) => {
|
||||||
|
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle proxy errors
|
||||||
|
proxy.on('error', (error) => {
|
||||||
|
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Handle proxy errors
|
// 7. Wait for proxy readiness via TCP polling
|
||||||
proxy.on('error', (error) => {
|
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
|
||||||
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
readySpinner.start();
|
||||||
});
|
|
||||||
|
|
||||||
// 7. Wait for proxy readiness via TCP polling
|
try {
|
||||||
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
|
await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval);
|
||||||
readySpinner.start();
|
readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`);
|
||||||
|
} catch (error) {
|
||||||
|
readySpinner.fail('CLIProxy startup failed');
|
||||||
|
proxy.kill('SIGTERM');
|
||||||
|
|
||||||
try {
|
const err = error as Error;
|
||||||
await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval);
|
console.error('');
|
||||||
readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`);
|
console.error(fail('CLIProxy failed to start'));
|
||||||
} catch (error) {
|
console.error('');
|
||||||
readySpinner.fail('CLIProxy startup failed');
|
console.error('Possible causes:');
|
||||||
proxy.kill('SIGTERM');
|
console.error(` 1. Port ${cfg.port} already in use`);
|
||||||
|
console.error(' 2. Binary crashed on startup');
|
||||||
|
console.error(' 3. Invalid configuration');
|
||||||
|
console.error('');
|
||||||
|
console.error('Troubleshooting:');
|
||||||
|
console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`);
|
||||||
|
console.error(' - Run with --verbose for detailed logs');
|
||||||
|
console.error(` - View config: ${getCatCommand(configPath)}`);
|
||||||
|
console.error(' - Try: ccs doctor --fix');
|
||||||
|
console.error('');
|
||||||
|
|
||||||
const err = error as Error;
|
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||||
console.error('');
|
}
|
||||||
console.error(fail('CLIProxy failed to start'));
|
|
||||||
console.error('');
|
|
||||||
console.error('Possible causes:');
|
|
||||||
console.error(` 1. Port ${cfg.port} already in use`);
|
|
||||||
console.error(' 2. Binary crashed on startup');
|
|
||||||
console.error(' 3. Invalid configuration');
|
|
||||||
console.error('');
|
|
||||||
console.error('Troubleshooting:');
|
|
||||||
console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`);
|
|
||||||
console.error(' - Run with --verbose for detailed logs');
|
|
||||||
console.error(` - View config: ${getCatCommand(configPath)}`);
|
|
||||||
console.error(' - Try: ccs doctor --fix');
|
|
||||||
console.error('');
|
|
||||||
|
|
||||||
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})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Execute Claude CLI with proxied environment
|
// 7. Execute Claude CLI with proxied environment
|
||||||
@@ -435,10 +473,25 @@ export async function execClaudeWithCLIProxy(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Cleanup: kill proxy when Claude exits
|
// 8. Cleanup: unregister session when Claude exits, kill proxy only if last session
|
||||||
claude.on('exit', (code, signal) => {
|
claude.on('exit', (code, signal) => {
|
||||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||||
proxy.kill('SIGTERM');
|
|
||||||
|
// Unregister this session - returns true if we were the last session
|
||||||
|
const shouldKillProxy = unregisterSession(sessionId);
|
||||||
|
log(`Session ${sessionId} unregistered, shouldKillProxy=${shouldKillProxy}`);
|
||||||
|
|
||||||
|
if (shouldKillProxy && proxy) {
|
||||||
|
// We were the last session and we own the proxy - kill it
|
||||||
|
log('Last session, killing proxy');
|
||||||
|
proxy.kill('SIGTERM');
|
||||||
|
} else if (shouldKillProxy && isReusingProxy) {
|
||||||
|
// We were the last session but don't own the proxy process
|
||||||
|
// The proxy will be cleaned up as zombie on next session start
|
||||||
|
log('Last session but reusing proxy, proxy will be cleaned up later');
|
||||||
|
} else {
|
||||||
|
log(`Other sessions still active, keeping proxy running`);
|
||||||
|
}
|
||||||
|
|
||||||
if (signal) {
|
if (signal) {
|
||||||
process.kill(process.pid, signal as NodeJS.Signals);
|
process.kill(process.pid, signal as NodeJS.Signals);
|
||||||
@@ -449,27 +502,39 @@ export async function execClaudeWithCLIProxy(
|
|||||||
|
|
||||||
claude.on('error', (error) => {
|
claude.on('error', (error) => {
|
||||||
console.error(fail(`Claude CLI error: ${error}`));
|
console.error(fail(`Claude CLI error: ${error}`));
|
||||||
proxy.kill('SIGTERM');
|
|
||||||
|
// Unregister and conditionally kill proxy
|
||||||
|
const shouldKillProxy = unregisterSession(sessionId);
|
||||||
|
if (shouldKillProxy && proxy) {
|
||||||
|
proxy.kill('SIGTERM');
|
||||||
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle parent process termination (SIGTERM, SIGINT)
|
// Handle parent process termination (SIGTERM, SIGINT)
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
log('Parent signal received, cleaning up');
|
log('Parent signal received, cleaning up');
|
||||||
proxy.kill('SIGTERM');
|
|
||||||
|
// Unregister and conditionally kill proxy
|
||||||
|
const shouldKillProxy = unregisterSession(sessionId);
|
||||||
|
if (shouldKillProxy && proxy) {
|
||||||
|
proxy.kill('SIGTERM');
|
||||||
|
}
|
||||||
claude.kill('SIGTERM');
|
claude.kill('SIGTERM');
|
||||||
};
|
};
|
||||||
|
|
||||||
process.once('SIGTERM', cleanup);
|
process.once('SIGTERM', cleanup);
|
||||||
process.once('SIGINT', cleanup);
|
process.once('SIGINT', cleanup);
|
||||||
|
|
||||||
// Handle proxy crash
|
// Handle proxy crash (only if we own the proxy)
|
||||||
proxy.on('exit', (code, signal) => {
|
if (proxy) {
|
||||||
if (code !== 0 && code !== null) {
|
proxy.on('exit', (code, signal) => {
|
||||||
log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`);
|
if (code !== 0 && code !== null) {
|
||||||
// Don't kill Claude - it may have already exited
|
log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`);
|
||||||
}
|
// Don't kill Claude - it may have already exited
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
/** Session lock file structure */
|
||||||
|
interface SessionLock {
|
||||||
|
port: number;
|
||||||
|
pid: number;
|
||||||
|
sessions: string[];
|
||||||
|
startedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate unique session ID */
|
||||||
|
function generateSessionId(): string {
|
||||||
|
return crypto.randomBytes(8).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get path to session lock file */
|
||||||
|
function getSessionLockPath(): string {
|
||||||
|
return path.join(getCliproxyDir(), 'sessions.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read session lock file (returns null if not exists or invalid) */
|
||||||
|
function readSessionLock(): SessionLock | null {
|
||||||
|
const lockPath = getSessionLockPath();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write session lock file */
|
||||||
|
function writeSessionLock(lock: SessionLock): void {
|
||||||
|
const lockPath = getSessionLockPath();
|
||||||
|
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 */
|
||||||
|
function deleteSessionLock(): void {
|
||||||
|
const lockPath = getSessionLockPath();
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(lockPath)) {
|
||||||
|
fs.unlinkSync(lockPath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors on cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = readSessionLock();
|
||||||
|
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
|
||||||
|
deleteSessionLock();
|
||||||
|
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.
|
||||||
|
* @returns Session ID for this session
|
||||||
|
*/
|
||||||
|
export function registerSession(port: number, proxyPid: number): string {
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
const existingLock = readSessionLock();
|
||||||
|
|
||||||
|
if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) {
|
||||||
|
// Add to existing sessions
|
||||||
|
existingLock.sessions.push(sessionId);
|
||||||
|
writeSessionLock(existingLock);
|
||||||
|
} else {
|
||||||
|
// Create new lock (first session for this proxy)
|
||||||
|
const newLock: SessionLock = {
|
||||||
|
port,
|
||||||
|
pid: proxyPid,
|
||||||
|
sessions: [sessionId],
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
writeSessionLock(newLock);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a session from the proxy.
|
||||||
|
* @returns true if this was the last session (proxy should be killed)
|
||||||
|
*/
|
||||||
|
export function unregisterSession(sessionId: string): boolean {
|
||||||
|
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
|
||||||
|
writeSessionLock(lock);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current session count for the proxy.
|
||||||
|
*/
|
||||||
|
export function getSessionCount(): number {
|
||||||
|
const lock = readSessionLock();
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function hasActiveSessions(): boolean {
|
||||||
|
const lock = readSessionLock();
|
||||||
|
if (!lock) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify proxy is still running
|
||||||
|
if (!isProcessRunning(lock.pid)) {
|
||||||
|
deleteSessionLock();
|
||||||
|
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 = readSessionLock();
|
||||||
|
if (!lock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If port doesn't match, this lock is for a different proxy
|
||||||
|
if (lock.port !== port) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If proxy is dead, clean up lock
|
||||||
|
if (!isProcessRunning(lock.pid)) {
|
||||||
|
deleteSessionLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* Session Tracker Tests
|
||||||
|
*
|
||||||
|
* Tests for multi-instance CLIProxy session management.
|
||||||
|
* Verifies reference counting and cleanup behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Set test isolation environment before importing
|
||||||
|
const testHome = path.join(os.tmpdir(), `ccs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
process.env.CCS_HOME = testHome;
|
||||||
|
|
||||||
|
const {
|
||||||
|
getExistingProxy,
|
||||||
|
registerSession,
|
||||||
|
unregisterSession,
|
||||||
|
getSessionCount,
|
||||||
|
hasActiveSessions,
|
||||||
|
cleanupOrphanedSessions,
|
||||||
|
} = require('../../../dist/cliproxy/session-tracker');
|
||||||
|
|
||||||
|
describe('Session Tracker', function () {
|
||||||
|
const testPort = 18317;
|
||||||
|
let sessionLockPath;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
// Create test directories
|
||||||
|
const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
|
||||||
|
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||||
|
sessionLockPath = path.join(cliproxyDir, 'sessions.json');
|
||||||
|
|
||||||
|
// Clean up any existing lock file
|
||||||
|
if (fs.existsSync(sessionLockPath)) {
|
||||||
|
fs.unlinkSync(sessionLockPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
// Clean up lock file
|
||||||
|
if (fs.existsSync(sessionLockPath)) {
|
||||||
|
fs.unlinkSync(sessionLockPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(function () {
|
||||||
|
// Clean up test directory
|
||||||
|
try {
|
||||||
|
fs.rmSync(testHome, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getExistingProxy', function () {
|
||||||
|
it('should return null when no lock file exists', function () {
|
||||||
|
const result = getExistingProxy(testPort);
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when port does not match', function () {
|
||||||
|
// Create lock with different port
|
||||||
|
const lock = {
|
||||||
|
port: 9999,
|
||||||
|
pid: process.pid, // Use current process as it's definitely running
|
||||||
|
sessions: ['session1'],
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||||
|
|
||||||
|
const result = getExistingProxy(testPort);
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return lock when proxy is healthy', function () {
|
||||||
|
// Create lock with current process (guaranteed to be running)
|
||||||
|
const lock = {
|
||||||
|
port: testPort,
|
||||||
|
pid: process.pid,
|
||||||
|
sessions: ['session1'],
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||||
|
|
||||||
|
const result = getExistingProxy(testPort);
|
||||||
|
assert.notStrictEqual(result, null);
|
||||||
|
assert.strictEqual(result.port, testPort);
|
||||||
|
assert.strictEqual(result.pid, process.pid);
|
||||||
|
assert.deepStrictEqual(result.sessions, ['session1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null and cleanup when proxy is dead', function () {
|
||||||
|
// Create lock with non-existent PID
|
||||||
|
const lock = {
|
||||||
|
port: testPort,
|
||||||
|
pid: 999999999, // Very unlikely to exist
|
||||||
|
sessions: ['session1'],
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||||
|
|
||||||
|
const result = getExistingProxy(testPort);
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
|
||||||
|
// Lock file should be cleaned up
|
||||||
|
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerSession', function () {
|
||||||
|
it('should create new lock file for first session', function () {
|
||||||
|
const sessionId = registerSession(testPort, process.pid);
|
||||||
|
|
||||||
|
assert.ok(sessionId, 'should return session ID');
|
||||||
|
assert.ok(fs.existsSync(sessionLockPath), 'should create lock file');
|
||||||
|
|
||||||
|
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||||
|
assert.strictEqual(lock.port, testPort);
|
||||||
|
assert.strictEqual(lock.pid, process.pid);
|
||||||
|
assert.strictEqual(lock.sessions.length, 1);
|
||||||
|
assert.strictEqual(lock.sessions[0], sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add session to existing lock', function () {
|
||||||
|
// Register first session
|
||||||
|
const session1 = registerSession(testPort, process.pid);
|
||||||
|
|
||||||
|
// Register second session
|
||||||
|
const session2 = registerSession(testPort, process.pid);
|
||||||
|
|
||||||
|
assert.notStrictEqual(session1, session2, 'should generate unique IDs');
|
||||||
|
|
||||||
|
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||||
|
assert.strictEqual(lock.sessions.length, 2);
|
||||||
|
assert.ok(lock.sessions.includes(session1));
|
||||||
|
assert.ok(lock.sessions.includes(session2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create new lock if PID differs', function () {
|
||||||
|
// Create lock with different PID
|
||||||
|
const oldLock = {
|
||||||
|
port: testPort,
|
||||||
|
pid: 12345,
|
||||||
|
sessions: ['old-session'],
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(sessionLockPath, JSON.stringify(oldLock));
|
||||||
|
|
||||||
|
// Register with new PID
|
||||||
|
const sessionId = registerSession(testPort, process.pid);
|
||||||
|
|
||||||
|
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||||
|
assert.strictEqual(lock.pid, process.pid);
|
||||||
|
assert.strictEqual(lock.sessions.length, 1);
|
||||||
|
assert.strictEqual(lock.sessions[0], sessionId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unregisterSession', function () {
|
||||||
|
it('should return true when no lock exists', function () {
|
||||||
|
const result = unregisterSession('nonexistent');
|
||||||
|
assert.strictEqual(result, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove session and return false when others remain', function () {
|
||||||
|
// Register two sessions
|
||||||
|
const session1 = registerSession(testPort, process.pid);
|
||||||
|
const session2 = registerSession(testPort, process.pid);
|
||||||
|
|
||||||
|
// Unregister first
|
||||||
|
const shouldKill = unregisterSession(session1);
|
||||||
|
|
||||||
|
assert.strictEqual(shouldKill, false, 'should not kill - other sessions active');
|
||||||
|
|
||||||
|
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||||
|
assert.strictEqual(lock.sessions.length, 1);
|
||||||
|
assert.strictEqual(lock.sessions[0], session2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true and delete lock when last session', function () {
|
||||||
|
// Register single session
|
||||||
|
const session1 = registerSession(testPort, process.pid);
|
||||||
|
|
||||||
|
// Unregister it
|
||||||
|
const shouldKill = unregisterSession(session1);
|
||||||
|
|
||||||
|
assert.strictEqual(shouldKill, true, 'should kill - last session');
|
||||||
|
assert.strictEqual(fs.existsSync(sessionLockPath), false, 'should delete lock file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unregistering non-existent session gracefully', function () {
|
||||||
|
// Register a session
|
||||||
|
registerSession(testPort, process.pid);
|
||||||
|
|
||||||
|
// Try to unregister wrong session
|
||||||
|
const shouldKill = unregisterSession('wrong-session-id');
|
||||||
|
|
||||||
|
// Should return false since a session still exists
|
||||||
|
assert.strictEqual(shouldKill, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSessionCount', function () {
|
||||||
|
it('should return 0 when no lock exists', function () {
|
||||||
|
assert.strictEqual(getSessionCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct count', function () {
|
||||||
|
registerSession(testPort, process.pid);
|
||||||
|
assert.strictEqual(getSessionCount(), 1);
|
||||||
|
|
||||||
|
registerSession(testPort, process.pid);
|
||||||
|
assert.strictEqual(getSessionCount(), 2);
|
||||||
|
|
||||||
|
registerSession(testPort, process.pid);
|
||||||
|
assert.strictEqual(getSessionCount(), 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasActiveSessions', function () {
|
||||||
|
it('should return false when no lock exists', function () {
|
||||||
|
assert.strictEqual(hasActiveSessions(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when sessions exist and proxy running', function () {
|
||||||
|
registerSession(testPort, process.pid);
|
||||||
|
assert.strictEqual(hasActiveSessions(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false and cleanup when proxy is dead', function () {
|
||||||
|
// Create lock with dead PID
|
||||||
|
const lock = {
|
||||||
|
port: testPort,
|
||||||
|
pid: 999999999,
|
||||||
|
sessions: ['session1'],
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||||
|
|
||||||
|
assert.strictEqual(hasActiveSessions(), false);
|
||||||
|
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanupOrphanedSessions', function () {
|
||||||
|
it('should do nothing when no lock exists', function () {
|
||||||
|
cleanupOrphanedSessions(testPort);
|
||||||
|
// Should not throw
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not cleanup when port differs', function () {
|
||||||
|
const lock = {
|
||||||
|
port: 9999,
|
||||||
|
pid: 999999999, // Dead PID
|
||||||
|
sessions: ['session1'],
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||||
|
|
||||||
|
cleanupOrphanedSessions(testPort);
|
||||||
|
|
||||||
|
// Should still exist (different port)
|
||||||
|
assert.strictEqual(fs.existsSync(sessionLockPath), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cleanup when proxy is dead', function () {
|
||||||
|
const lock = {
|
||||||
|
port: testPort,
|
||||||
|
pid: 999999999, // Dead PID
|
||||||
|
sessions: ['session1'],
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||||
|
|
||||||
|
cleanupOrphanedSessions(testPort);
|
||||||
|
|
||||||
|
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not cleanup when proxy is alive', function () {
|
||||||
|
const lock = {
|
||||||
|
port: testPort,
|
||||||
|
pid: process.pid, // Current process - alive
|
||||||
|
sessions: ['session1'],
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||||
|
|
||||||
|
cleanupOrphanedSessions(testPort);
|
||||||
|
|
||||||
|
assert.strictEqual(fs.existsSync(sessionLockPath), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multi-session scenario', function () {
|
||||||
|
it('should handle complete multi-terminal workflow', function () {
|
||||||
|
// Terminal 1 starts - first session
|
||||||
|
const session1 = registerSession(testPort, process.pid);
|
||||||
|
assert.strictEqual(getSessionCount(), 1);
|
||||||
|
|
||||||
|
// Terminal 2 starts - joins existing
|
||||||
|
const session2 = registerSession(testPort, process.pid);
|
||||||
|
assert.strictEqual(getSessionCount(), 2);
|
||||||
|
|
||||||
|
// Terminal 3 starts - joins existing
|
||||||
|
const session3 = registerSession(testPort, process.pid);
|
||||||
|
assert.strictEqual(getSessionCount(), 3);
|
||||||
|
|
||||||
|
// Terminal 1 exits - should NOT kill proxy
|
||||||
|
let shouldKill = unregisterSession(session1);
|
||||||
|
assert.strictEqual(shouldKill, false);
|
||||||
|
assert.strictEqual(getSessionCount(), 2);
|
||||||
|
|
||||||
|
// Terminal 3 exits - should NOT kill proxy
|
||||||
|
shouldKill = unregisterSession(session3);
|
||||||
|
assert.strictEqual(shouldKill, false);
|
||||||
|
assert.strictEqual(getSessionCount(), 1);
|
||||||
|
|
||||||
|
// Terminal 2 exits - SHOULD kill proxy (last session)
|
||||||
|
shouldKill = unregisterSession(session2);
|
||||||
|
assert.strictEqual(shouldKill, true);
|
||||||
|
assert.strictEqual(getSessionCount(), 0);
|
||||||
|
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user