mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
Enables running multiple CLIProxy variants simultaneously on different ports. Each variant now gets a unique port in the 18100-18199 range, allowing concurrent use of providers like Gemini + Codex + custom variants. Key changes: - Add port field to variant config schema - Implement automatic port allocation (18100 + variant index) - Support variant-specific settings paths in config generator - Display port in dashboard UI for debugging - Show all models in variant editor dropdown - Add comprehensive tests for port allocation edge cases Closes related variant isolation work.
502 lines
16 KiB
JavaScript
502 lines
16 KiB
JavaScript
/**
|
|
* Variant Port Edge Case Tests
|
|
*
|
|
* Tests for edge cases and error handling in variant port isolation.
|
|
* Covers port exhaustion, race conditions, stale session cleanup,
|
|
* legacy migration, permission errors, and config corruption.
|
|
*/
|
|
|
|
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-edge-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
);
|
|
process.env.CCS_HOME = testHome;
|
|
|
|
const {
|
|
getNextAvailablePort,
|
|
VARIANT_PORT_BASE,
|
|
VARIANT_PORT_MAX_OFFSET,
|
|
listVariantsFromConfig,
|
|
saveVariantLegacy,
|
|
removeVariantFromLegacyConfig,
|
|
} = require('../../../dist/cliproxy/services/variant-config-adapter');
|
|
const {
|
|
getExistingProxy,
|
|
registerSession,
|
|
unregisterSession,
|
|
cleanupOrphanedSessions,
|
|
deleteSessionLockForPort,
|
|
getProxyStatus,
|
|
} = require('../../../dist/cliproxy/session-tracker');
|
|
const {
|
|
deleteConfigForPort,
|
|
configExists,
|
|
generateConfig,
|
|
} = require('../../../dist/cliproxy/config-generator');
|
|
|
|
describe('Variant Port Edge Cases', function () {
|
|
let configPath;
|
|
let cliproxyDir;
|
|
|
|
beforeEach(function () {
|
|
// Create test directories
|
|
const ccsDir = path.join(testHome, '.ccs');
|
|
cliproxyDir = path.join(ccsDir, 'cliproxy');
|
|
fs.mkdirSync(cliproxyDir, { recursive: true });
|
|
configPath = path.join(ccsDir, 'config.json');
|
|
|
|
// Start with empty config
|
|
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
|
|
});
|
|
|
|
afterEach(function () {
|
|
// Clean up config and session files
|
|
try {
|
|
if (fs.existsSync(configPath)) {
|
|
fs.unlinkSync(configPath);
|
|
}
|
|
const files = fs.readdirSync(cliproxyDir);
|
|
for (const file of files) {
|
|
fs.unlinkSync(path.join(cliproxyDir, file));
|
|
}
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
afterAll(function () {
|
|
// Clean up test directory
|
|
try {
|
|
fs.rmSync(testHome, { recursive: true, force: true });
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
delete process.env.CCS_HOME;
|
|
});
|
|
|
|
describe('Port Exhaustion', function () {
|
|
it('throws after 100 variants created', function () {
|
|
const ccsDir = path.join(testHome, '.ccs');
|
|
|
|
// Create 100 variants
|
|
for (let i = 0; i < 100; i++) {
|
|
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
|
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
|
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i);
|
|
}
|
|
|
|
assert.throws(() => getNextAvailablePort(), /Port limit reached/);
|
|
});
|
|
|
|
it('error message shows 100/100 and recovery hint', function () {
|
|
const ccsDir = path.join(testHome, '.ccs');
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
|
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
|
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i);
|
|
}
|
|
|
|
try {
|
|
getNextAvailablePort();
|
|
assert.fail('Should have thrown');
|
|
} catch (err) {
|
|
assert.ok(err.message.includes('100/100'));
|
|
assert.ok(err.message.includes('ccs cliproxy remove'));
|
|
}
|
|
});
|
|
|
|
it('frees port after variant removal', function () {
|
|
const ccsDir = path.join(testHome, '.ccs');
|
|
|
|
// Create variant on port 8318
|
|
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
|
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
|
saveVariantLegacy('test', 'gemini', settingsPath, undefined, VARIANT_PORT_BASE);
|
|
|
|
// Next should be 8319
|
|
assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE + 1);
|
|
|
|
// Remove variant
|
|
removeVariantFromLegacyConfig('test');
|
|
|
|
// 8318 should be free again
|
|
assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE);
|
|
});
|
|
});
|
|
|
|
describe('Stale Session Cleanup', function () {
|
|
it('cleans orphaned session lock on variant delete', function () {
|
|
const port = 8318;
|
|
|
|
// Create session file
|
|
registerSession(port, process.pid);
|
|
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
|
assert.ok(fs.existsSync(sessionPath));
|
|
|
|
// Simulate variant delete cleanup
|
|
deleteSessionLockForPort(port);
|
|
assert.strictEqual(fs.existsSync(sessionPath), false);
|
|
});
|
|
|
|
it('cleans stale lock when PID not running', function () {
|
|
const port = 8318;
|
|
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
|
|
|
// Create lock with dead PID
|
|
fs.writeFileSync(
|
|
sessionPath,
|
|
JSON.stringify({
|
|
port,
|
|
pid: 999999999, // Dead PID
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
})
|
|
);
|
|
|
|
// getExistingProxy should clean it up
|
|
const lock = getExistingProxy(port);
|
|
assert.strictEqual(lock, null);
|
|
assert.strictEqual(fs.existsSync(sessionPath), false);
|
|
});
|
|
});
|
|
|
|
describe('Legacy Variant Migration', function () {
|
|
it('variant without port gets undefined in listing', function () {
|
|
// Create legacy variant without port
|
|
const config = {
|
|
profiles: {},
|
|
cliproxy: {
|
|
legacy: {
|
|
provider: 'gemini',
|
|
settings: '/path/to/settings.json',
|
|
// No port field
|
|
},
|
|
},
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
const variants = listVariantsFromConfig();
|
|
assert.strictEqual(variants.legacy.port, undefined);
|
|
});
|
|
|
|
it('legacy variant does not block port allocation', function () {
|
|
// Create legacy variant without port
|
|
const config = {
|
|
profiles: {},
|
|
cliproxy: {
|
|
legacy: {
|
|
provider: 'gemini',
|
|
settings: '/path/to/settings.json',
|
|
// No port field - doesn't count toward port usage
|
|
},
|
|
},
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
// First port should still be available
|
|
const port = getNextAvailablePort();
|
|
assert.strictEqual(port, VARIANT_PORT_BASE);
|
|
});
|
|
});
|
|
|
|
describe('Config Corruption', function () {
|
|
it('handles malformed sessions-{port}.json gracefully', function () {
|
|
const port = 8318;
|
|
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
|
|
|
// Write invalid JSON
|
|
fs.writeFileSync(sessionPath, '{ invalid json }');
|
|
|
|
// getExistingProxy should return null, not throw
|
|
const lock = getExistingProxy(port);
|
|
assert.strictEqual(lock, null);
|
|
});
|
|
|
|
it('handles missing config-{port}.yaml gracefully', function () {
|
|
const port = 8318;
|
|
// configExists should return false, not throw
|
|
assert.strictEqual(configExists(port), false);
|
|
});
|
|
|
|
it('handles sessions file with missing required fields', function () {
|
|
const port = 8318;
|
|
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
|
|
|
// Write JSON missing required fields
|
|
fs.writeFileSync(sessionPath, JSON.stringify({ port: 8318 }));
|
|
|
|
// getExistingProxy should return null (invalid structure)
|
|
const lock = getExistingProxy(port);
|
|
assert.strictEqual(lock, null);
|
|
});
|
|
|
|
it('handles sessions file with wrong data types', function () {
|
|
const port = 8318;
|
|
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
|
|
|
// Write JSON with wrong types
|
|
fs.writeFileSync(
|
|
sessionPath,
|
|
JSON.stringify({
|
|
port: 'not-a-number',
|
|
pid: 'also-not-a-number',
|
|
sessions: 'not-an-array',
|
|
})
|
|
);
|
|
|
|
const lock = getExistingProxy(port);
|
|
assert.strictEqual(lock, null);
|
|
});
|
|
});
|
|
|
|
describe('Cleanup on Crash', function () {
|
|
it('getExistingProxy cleans stale lock if PID dead', function () {
|
|
const port = 8318;
|
|
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
|
|
|
// Create lock with dead PID (simulating crashed proxy)
|
|
fs.writeFileSync(
|
|
sessionPath,
|
|
JSON.stringify({
|
|
port,
|
|
pid: 999999999,
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
})
|
|
);
|
|
|
|
const lock = getExistingProxy(port);
|
|
assert.strictEqual(lock, null);
|
|
assert.strictEqual(fs.existsSync(sessionPath), false);
|
|
});
|
|
|
|
it('cleanupOrphanedSessions removes stale lock', function () {
|
|
const port = 8318;
|
|
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
|
|
|
// Create lock with dead PID
|
|
fs.writeFileSync(
|
|
sessionPath,
|
|
JSON.stringify({
|
|
port,
|
|
pid: 999999999,
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
})
|
|
);
|
|
|
|
cleanupOrphanedSessions(port);
|
|
assert.strictEqual(fs.existsSync(sessionPath), false);
|
|
});
|
|
|
|
it('variant delete cleans session lock even if proxy crashed', function () {
|
|
const port = 8318;
|
|
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
|
|
|
// Create lock with dead PID
|
|
fs.writeFileSync(
|
|
sessionPath,
|
|
JSON.stringify({
|
|
port,
|
|
pid: 999999999,
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
})
|
|
);
|
|
|
|
// deleteSessionLockForPort is called during variant removal
|
|
deleteSessionLockForPort(port);
|
|
assert.strictEqual(fs.existsSync(sessionPath), false);
|
|
});
|
|
});
|
|
|
|
describe('Variant Lifecycle Integration', function () {
|
|
it('creates variant with unique port and separate files', function () {
|
|
const ccsDir = path.join(testHome, '.ccs');
|
|
const port = getNextAvailablePort();
|
|
|
|
// Create variant
|
|
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
|
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
|
saveVariantLegacy('test', 'gemini', settingsPath, undefined, port);
|
|
|
|
// Generate config
|
|
generateConfig('gemini', port);
|
|
|
|
// Verify files exist
|
|
assert.ok(configExists(port));
|
|
|
|
// Start session
|
|
const sessionId = registerSession(port, process.pid);
|
|
const status = getProxyStatus(port);
|
|
assert.strictEqual(status.running, true);
|
|
assert.strictEqual(status.port, port);
|
|
|
|
// Clean up session
|
|
unregisterSession(sessionId, port);
|
|
});
|
|
|
|
it('removes variant and cleans up all port files', function () {
|
|
const ccsDir = path.join(testHome, '.ccs');
|
|
const port = 8318;
|
|
|
|
// Create variant
|
|
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
|
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
|
saveVariantLegacy('test', 'gemini', settingsPath, undefined, port);
|
|
|
|
// Generate config and session
|
|
generateConfig('gemini', port);
|
|
registerSession(port, process.pid);
|
|
|
|
// Verify files exist
|
|
assert.ok(configExists(port));
|
|
assert.strictEqual(getProxyStatus(port).running, true);
|
|
|
|
// Remove variant (simulating full cleanup)
|
|
removeVariantFromLegacyConfig('test');
|
|
deleteConfigForPort(port);
|
|
deleteSessionLockForPort(port);
|
|
|
|
// Verify cleanup
|
|
assert.strictEqual(configExists(port), false);
|
|
assert.strictEqual(getProxyStatus(port).running, false);
|
|
});
|
|
|
|
it('port reuse after deletion does not have stale data', function () {
|
|
const ccsDir = path.join(testHome, '.ccs');
|
|
|
|
// Create variant A
|
|
const settingsA = path.join(ccsDir, 'variantA.settings.json');
|
|
fs.writeFileSync(settingsA, JSON.stringify({ env: { KEY: 'A' } }));
|
|
const portA = getNextAvailablePort();
|
|
saveVariantLegacy('variantA', 'gemini', settingsA, undefined, portA);
|
|
generateConfig('gemini', portA);
|
|
registerSession(portA, process.pid);
|
|
|
|
// Remove variant A with full cleanup
|
|
removeVariantFromLegacyConfig('variantA');
|
|
deleteConfigForPort(portA);
|
|
deleteSessionLockForPort(portA);
|
|
|
|
// Create variant B - should get same port
|
|
const settingsB = path.join(ccsDir, 'variantB.settings.json');
|
|
fs.writeFileSync(settingsB, JSON.stringify({ env: { KEY: 'B' } }));
|
|
const portB = getNextAvailablePort();
|
|
assert.strictEqual(portB, portA); // Port should be reused
|
|
|
|
// New session should start fresh
|
|
saveVariantLegacy('variantB', 'gemini', settingsB, undefined, portB);
|
|
generateConfig('gemini', portB);
|
|
const sessionB = registerSession(portB, process.pid);
|
|
|
|
const status = getProxyStatus(portB);
|
|
assert.strictEqual(status.running, true);
|
|
assert.strictEqual(status.sessionCount, 1);
|
|
|
|
// Clean up
|
|
unregisterSession(sessionB, portB);
|
|
});
|
|
});
|
|
|
|
describe('Multiple Concurrent Variants', function () {
|
|
it('creates 3 variants with different ports', function () {
|
|
const ccsDir = path.join(testHome, '.ccs');
|
|
const ports = [];
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const port = getNextAvailablePort();
|
|
ports.push(port);
|
|
|
|
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
|
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
|
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
|
}
|
|
|
|
// Verify all ports are different
|
|
const uniquePorts = new Set(ports);
|
|
assert.strictEqual(uniquePorts.size, 3);
|
|
|
|
// Verify sequential assignment
|
|
assert.strictEqual(ports[0], VARIANT_PORT_BASE);
|
|
assert.strictEqual(ports[1], VARIANT_PORT_BASE + 1);
|
|
assert.strictEqual(ports[2], VARIANT_PORT_BASE + 2);
|
|
});
|
|
|
|
it('each has separate config file', function () {
|
|
const ccsDir = path.join(testHome, '.ccs');
|
|
const ports = [8318, 8319, 8320];
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
|
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
|
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
|
|
generateConfig('gemini', ports[i]);
|
|
}
|
|
|
|
// Verify separate config files
|
|
for (const port of ports) {
|
|
assert.ok(configExists(port), `Config for port ${port} should exist`);
|
|
}
|
|
});
|
|
|
|
it('each has separate sessions file when running', function () {
|
|
const ports = [8318, 8319, 8320];
|
|
|
|
for (const port of ports) {
|
|
registerSession(port, process.pid);
|
|
}
|
|
|
|
// Verify separate session files
|
|
for (const port of ports) {
|
|
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
|
assert.ok(fs.existsSync(sessionPath), `Session file for port ${port} should exist`);
|
|
}
|
|
|
|
// Clean up
|
|
for (const port of ports) {
|
|
deleteSessionLockForPort(port);
|
|
}
|
|
});
|
|
|
|
it('removing one does not affect others', function () {
|
|
const ccsDir = path.join(testHome, '.ccs');
|
|
const ports = [8318, 8319, 8320];
|
|
|
|
// Create 3 variants
|
|
for (let i = 0; i < 3; i++) {
|
|
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
|
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
|
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
|
|
generateConfig('gemini', ports[i]);
|
|
registerSession(ports[i], process.pid);
|
|
}
|
|
|
|
// Remove middle variant
|
|
removeVariantFromLegacyConfig('variant1');
|
|
deleteConfigForPort(ports[1]);
|
|
deleteSessionLockForPort(ports[1]);
|
|
|
|
// Verify others still exist
|
|
assert.ok(configExists(ports[0]));
|
|
assert.ok(!configExists(ports[1])); // Removed
|
|
assert.ok(configExists(ports[2]));
|
|
|
|
assert.strictEqual(getProxyStatus(ports[0]).running, true);
|
|
assert.strictEqual(getProxyStatus(ports[1]).running, false); // Removed
|
|
assert.strictEqual(getProxyStatus(ports[2]).running, true);
|
|
|
|
// Clean up remaining
|
|
deleteSessionLockForPort(ports[0]);
|
|
deleteSessionLockForPort(ports[2]);
|
|
});
|
|
});
|
|
});
|