diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 80f19ea4..3b69a2f6 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -45,6 +45,13 @@ import { installWebSearchHook, displayWebSearchStatus, } from '../utils/websearch-manager'; +import { + getExistingProxy, + registerSession, + unregisterSession, + hasActiveSessions, + cleanupOrphanedSessions, +} from './session-tracker'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -303,85 +310,116 @@ export async function execClaudeWithCLIProxy( const configPath = generateConfig(provider, cfg.port); log(`Config written: ${configPath}`); - // 6a. Pre-flight check: ensure port is free (auto-kill zombie CLIProxy) - const portProcess = await getPortProcess(cfg.port); - if (portProcess) { - if (isCLIProxyProcess(portProcess)) { - // Zombie CLIProxy from previous run - auto-kill it - 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)); + // 6a. Pre-flight check: handle existing proxy or port conflicts + // Clean up orphaned sessions first (from crashed proxies) + cleanupOrphanedSessions(cfg.port); + + // Check if there's an existing healthy proxy we can reuse + const existingProxy = getExistingProxy(cfg.port); + let proxy: ChildProcess | null = null; + let sessionId: string; + let isReusingProxy = false; + + 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 - const proxyArgs = ['--config', configPath]; + // 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy) + const proxyArgs = ['--config', configPath]; - log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); + log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); - const proxy = spawn(binaryPath, proxyArgs, { - stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], - detached: false, - }); - - // Forward proxy output in verbose mode - if (verbose) { - proxy.stdout?.on('data', (data: Buffer) => { - process.stderr.write(`[cliproxy-out] ${data.toString()}`); + proxy = spawn(binaryPath, proxyArgs, { + stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], + detached: false, }); - 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 - proxy.on('error', (error) => { - console.error(fail(`CLIProxy spawn error: ${error.message}`)); - }); + // 7. Wait for proxy readiness via TCP polling + const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); + readySpinner.start(); - // 7. Wait for proxy readiness via TCP polling - const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); - readySpinner.start(); + try { + await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); + readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); + } catch (error) { + readySpinner.fail('CLIProxy startup failed'); + proxy.kill('SIGTERM'); - try { - await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); - readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); - } catch (error) { - readySpinner.fail('CLIProxy startup failed'); - proxy.kill('SIGTERM'); + const err = error as Error; + 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(''); - const err = error as Error; - 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}`); + } - 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 @@ -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) => { 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) { process.kill(process.pid, signal as NodeJS.Signals); @@ -449,27 +502,39 @@ export async function execClaudeWithCLIProxy( claude.on('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); }); // Handle parent process termination (SIGTERM, SIGINT) const cleanup = () => { 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'); }; process.once('SIGTERM', cleanup); process.once('SIGINT', cleanup); - // Handle proxy crash - proxy.on('exit', (code, signal) => { - if (code !== 0 && code !== null) { - log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`); - // Don't kill Claude - it may have already exited - } - }); + // Handle proxy crash (only if we own the proxy) + if (proxy) { + proxy.on('exit', (code, signal) => { + if (code !== 0 && code !== null) { + log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`); + // Don't kill Claude - it may have already exited + } + }); + } } /** diff --git a/src/cliproxy/session-tracker.ts b/src/cliproxy/session-tracker.ts new file mode 100644 index 00000000..0f1697c8 --- /dev/null +++ b/src/cliproxy/session-tracker.ts @@ -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(); + } +} diff --git a/tests/unit/cliproxy/session-tracker.test.js b/tests/unit/cliproxy/session-tracker.test.js new file mode 100644 index 00000000..6625885c --- /dev/null +++ b/tests/unit/cliproxy/session-tracker.test.js @@ -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); + }); + }); +});