diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 29b41c87..c3dca91d 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -133,29 +133,55 @@ async function handleStatus(args: string[] = []): Promise { return showHelp(); } const profileName = findPositionalArg(args); - const running = profileName - ? [await getOpenAICompatProxyStatus(profileName)].filter((status) => status.running) - : (await listOpenAICompatProxyStatuses()).filter((status) => status.running); - if (running.length === 0) { - console.log(info('Proxy is not running')); - return 0; - } - - for (const status of running) { - console.log(ok(`Proxy running on port ${status.port}`)); - if (status.host) { + const printStatus = (status: Awaited>) => { + console.log( + status.running + ? ok(`Proxy running on port ${status.port}`) + : info(`Proxy is not running${status.port ? ` (last known port ${status.port})` : ''}`) + ); + if (status.host && status.port) { console.log(` Host: ${status.host}`); console.log(` Local URL: http://${status.host}:${status.port}`); } - console.log(` Profile: ${status.profileName}`); - console.log(` Base URL: ${status.baseUrl}`); + if (status.profileName) { + console.log(` Profile: ${status.profileName}`); + } + if (status.baseUrl) { + console.log(` Base URL: ${status.baseUrl}`); + } if (status.model) { console.log(` Model: ${status.model}`); } if (status.pid) { console.log(` PID: ${status.pid}`); } + }; + + if (profileName) { + const status = await getOpenAICompatProxyStatus(profileName); + if (!status.running && !status.profileName) { + console.log(info('Proxy is not running')); + return 0; + } + printStatus(status); + return 0; } + + const status = await getOpenAICompatProxyStatus(); + if (!status.running && !status.profileName) { + console.log(info('Proxy is not running')); + return 0; + } + + if (status.running && !status.profileName) { + const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running); + for (const entry of running) { + printStatus(entry); + } + return 0; + } + + printStatus(status); return 0; } diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index b2d30fe1..427e442a 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -42,6 +42,11 @@ export interface StartOpenAICompatProxyResult { error?: string; } +interface OpenAICompatProxyLaunchResult extends StartOpenAICompatProxyResult { + commitState?: () => void; + stop?: () => Promise; +} + function generateProxyAuthToken(): string { return crypto.randomBytes(24).toString('hex'); } @@ -93,6 +98,30 @@ async function isPortOccupied(port: number): Promise { }); } +async function terminateDaemonProcess(pid?: number): Promise { + if (!pid) { + return; + } + + try { + process.kill(pid, 'SIGTERM'); + let attempts = 0; + while (attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 200)); + try { + process.kill(pid, 0); + attempts += 1; + } catch { + return; + } + } + + process.kill(pid, 'SIGKILL'); + } catch { + // Best-effort cleanup for a daemon we just spawned. + } +} + async function findOpenAICompatProxyPort( excludedPorts: ReadonlySet = new Set() ): Promise { @@ -397,17 +426,35 @@ export async function startOpenAICompatProxy( }; } - const startOnPort = (port: number): Promise => + const launchOnPort = ( + port: number, + persistState: boolean + ): Promise => new Promise((resolve) => { let resolved = false; let timeout: NodeJS.Timeout | null = null; const authToken = generateProxyAuthToken(); + const commitState = () => { + if (proc.pid) { + writeOpenAICompatProxyPid(profile.profileName, proc.pid); + } + writeOpenAICompatProxySession({ + profileName: profile.profileName, + settingsPath: profile.settingsPath, + host, + port, + baseUrl: profile.baseUrl, + authToken, + model: profile.model, + insecure: options.insecure, + }); + }; - const finish = (result: StartOpenAICompatProxyResult) => { + const finish = (result: OpenAICompatProxyLaunchResult) => { if (resolved) return; resolved = true; if (timeout) clearTimeout(timeout); - if (!result.success) { + if (!result.success && persistState) { removeOpenAICompatProxyPid(profile.profileName); removeOpenAICompatProxySession(profile.profileName); } @@ -435,25 +482,28 @@ export async function startOpenAICompatProxy( ); proc.unref(); - if (proc.pid) { - writeOpenAICompatProxyPid(profile.profileName, proc.pid); + if (persistState) { + commitState(); } - writeOpenAICompatProxySession({ - profileName: profile.profileName, - settingsPath: profile.settingsPath, - host, - port, - baseUrl: profile.baseUrl, - authToken, - model: profile.model, - insecure: options.insecure, - }); let attempts = 0; const poll = async () => { attempts += 1; if (await isOpenAICompatProxyRunning(port)) { - finish({ success: true, pid: proc.pid, port, authToken }); + finish({ + success: true, + pid: proc.pid, + port, + authToken, + ...(persistState + ? {} + : { + commitState, + stop: async () => { + await terminateDaemonProcess(proc.pid); + }, + }), + }); return; } if (attempts >= 30) { @@ -496,40 +546,64 @@ export async function startOpenAICompatProxy( }); }); - if (requiresExactPort) { - return startOnPort(preferredPort); - } + const launchProxy = async (persistState: boolean): Promise => { + if (requiresExactPort) { + return launchOnPort(preferredPort, persistState); + } - const attemptedPorts = new Set(); - let lastResult: StartOpenAICompatProxyResult | null = null; - for (let attempt = 0; attempt < 3; attempt += 1) { - const port = await findOpenAICompatProxyPortNear(preferredPort, attemptedPorts); - if (port === 0) { - return { + const attemptedPorts = new Set(); + let lastResult: OpenAICompatProxyLaunchResult | null = null; + for (let attempt = 0; attempt < 3; attempt += 1) { + const port = await findOpenAICompatProxyPortNear(preferredPort, attemptedPorts); + if (port === 0) { + return { + success: false, + port: preferredPort, + error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, + }; + } + + const result = await launchOnPort(port, persistState); + if (result.success) { + return result; + } + + lastResult = result; + attemptedPorts.add(port); + if (!(await isPortOccupied(port))) { + return result; + } + } + + return ( + lastResult ?? { success: false, port: preferredPort, - error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, + error: 'Failed to start proxy', + } + ); + }; + + if (status.running) { + const launched = await launchProxy(false); + if (!launched.success) { + return launched; + } + + const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName); + if (!stopped.success) { + await launched.stop?.(); + return { + success: false, + port: launched.port, + error: stopped.error || 'Failed to replace the running proxy', }; } - const result = await startOnPort(port); - if (result.success) { - return result; - } - - lastResult = result; - attemptedPorts.add(port); - if (!(await isPortOccupied(port))) { - return result; - } + launched.commitState?.(); + return launched; } - return ( - lastResult ?? { - success: false, - port: preferredPort, - error: 'Failed to start proxy', - } - ); + return launchProxy(true); }); } diff --git a/tests/e2e/proxy-command.e2e.test.ts b/tests/e2e/proxy-command.e2e.test.ts index 8c2a0f62..ff86ad5a 100644 --- a/tests/e2e/proxy-command.e2e.test.ts +++ b/tests/e2e/proxy-command.e2e.test.ts @@ -52,6 +52,34 @@ describe('proxy command e2e', () => { expect(help.stdout).toContain('stop [profile] Stop the running proxy (or all proxies when omitted)'); }); + it('shows the last-known proxy state when no proxy is currently running', async () => { + const stalePort = await getPort(); + const proxyDir = path.join(tempDir, '.ccs', 'proxy'); + fs.mkdirSync(proxyDir, { recursive: true }); + fs.writeFileSync( + path.join(proxyDir, 'stale.session.json'), + JSON.stringify( + { + profileName: 'stale', + settingsPath: path.join(tempDir, '.ccs', 'stale.settings.json'), + host: '127.0.0.1', + port: stalePort, + baseUrl: 'http://127.0.0.1:11434', + authToken: 'deadbeef', + model: 'qwen3-coder', + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const status = runCli(['proxy', 'status']); + expect(status.status).toBe(0); + expect(status.stdout).toContain(`Proxy is not running (last known port ${stalePort})`); + expect(status.stdout).toContain('Profile: stale'); + }); + it('starts, reports status, activates, and stops via the built CLI', async () => { const port = await getPort(); const ccsDir = path.join(tempDir, '.ccs'); diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 9ff9e5f6..3900f8d7 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -303,4 +303,53 @@ describe('openai proxy daemon lifecycle', () => { server.stop(true); } }); + + it('keeps the existing proxy running if replacement startup fails', async () => { + const firstPort = await getPort(); + const occupiedPort = await getPort(); + const busyServer = Bun.serve({ + port: occupiedPort, + hostname: '127.0.0.1', + fetch: () => new Response('busy'), + }); + + try { + const settingsPath = path.join(tempDir, 'rollback.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-rollback', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('rollback', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-rollback', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected a rollback OpenAI-compatible profile'); + } + + const firstStart = await startOpenAICompatProxy(profile, { port: firstPort }); + expect(firstStart.success).toBe(true); + + const restarted = await startOpenAICompatProxy(profile, { port: occupiedPort }); + expect(restarted.success).toBe(false); + + const status = await getOpenAICompatProxyStatus('rollback'); + expect(status.running).toBe(true); + expect(status.port).toBe(firstPort); + expect((await fetch(`http://127.0.0.1:${firstPort}/health`)).status).toBe(200); + } finally { + busyServer.stop(true); + } + }); });