From 391bdddc2232836f9e49dcfa710c7ad1ffac19b9 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 15:25:16 +0900 Subject: [PATCH] fix(proxy): handle legacy stop and preferred ports --- src/proxy/proxy-daemon.ts | 118 ++++++++++++++++-- .../proxy/daemon-lifecycle.test.ts | 112 +++++++++++++++++ 2 files changed, 218 insertions(+), 12 deletions(-) diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 8b5891dd..c27904f0 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -115,11 +115,7 @@ function listOpenAICompatProxyCandidatePorts( } const candidates = new Set(); - if ( - preferredPort >= OPENAI_COMPAT_PROXY_DEFAULT_PORT && - preferredPort <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10 && - !excludedPorts.has(preferredPort) - ) { + if (!excludedPorts.has(preferredPort)) { candidates.add(preferredPort); } @@ -215,6 +211,22 @@ async function getOpenAICompatProxyStatusForProfile( }; } +async function getLegacyOpenAICompatProxyStatus(): Promise { + const session = readLegacyOpenAICompatProxySession(); + const pid = getLegacyOpenAICompatProxyPid(); + if (!session && !pid) { + return null; + } + + const port = session?.port; + const running = typeof port === 'number' ? await isOpenAICompatProxyRunning(port) : false; + return { + running, + pid: running ? pid || undefined : pid || undefined, + ...session, + }; +} + function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatProxyStateRecord { const session = readOpenAICompatProxySession(profileName); if (session) { @@ -243,10 +255,17 @@ export async function listOpenAICompatProxyStatuses(): Promise getOpenAICompatProxyStatusForProfile(profileName)) ); - return statuses.filter((status) => status.profileName); + const statuses = profileStatuses.filter((status) => status.profileName); + if (!legacySession?.profileName) { + const legacyStatus = await getLegacyOpenAICompatProxyStatus(); + if (legacyStatus) { + statuses.push(legacyStatus); + } + } + return statuses; } export async function getOpenAICompatProxyStatus( @@ -333,6 +352,74 @@ async function stopOpenAICompatProxyUnlocked( return { success: true }; } +async function stopLegacyOpenAICompatProxyUnlocked(): Promise<{ + success: boolean; + error?: string; +}> { + const legacySession = readLegacyOpenAICompatProxySession(); + if (legacySession?.profileName) { + return stopOpenAICompatProxyUnlocked(legacySession.profileName); + } + + const pid = getLegacyOpenAICompatProxyPid(); + if (!pid) { + removeLegacyOpenAICompatProxyPid(); + removeLegacyOpenAICompatProxySession(); + return { success: true }; + } + + const ownership = verifyProcessOwnership( + pid, + (commandLine) => + commandLine.includes('--ccs-openai-proxy-daemon') && + commandLine.includes('proxy-daemon-entry') + ); + + if (ownership === 'not-owned' || ownership === 'not-running') { + removeLegacyOpenAICompatProxyPid(); + removeLegacyOpenAICompatProxySession(); + return { success: true }; + } + + if (ownership === 'unknown') { + return { + success: false, + error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`, + }; + } + + try { + process.kill(pid, 'SIGTERM'); + let attempts = 0; + while (attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + process.kill(pid, 0); + attempts += 1; + } catch { + break; + } + } + + if (attempts >= 10) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already exited. + } + } + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ESRCH') { + return { success: false, error: `Failed to stop daemon: ${err.message}` }; + } + } + + removeLegacyOpenAICompatProxyPid(); + removeLegacyOpenAICompatProxySession(); + return { success: true }; +} + function removeOpenAICompatProxyState( state: OpenAICompatProxyStateRecord, profileName: string @@ -356,15 +443,22 @@ export async function stopOpenAICompatProxy( } const statuses = await listOpenAICompatProxyStatuses(); + const failures: string[] = []; for (const status of statuses) { - if (!status.profileName) { - continue; - } - const stopped = await stopOpenAICompatProxyUnlocked(status.profileName); + const stopped = status.profileName + ? await stopOpenAICompatProxyUnlocked(status.profileName) + : await stopLegacyOpenAICompatProxyUnlocked(); if (!stopped.success) { - return stopped; + failures.push( + status.profileName + ? `${status.profileName}: ${stopped.error || 'failed to stop proxy'}` + : `legacy proxy: ${stopped.error || 'failed to stop proxy'}` + ); } } + if (failures.length > 0) { + return { success: false, error: `Failed to stop some proxies: ${failures.join('; ')}` }; + } return { success: true }; }); } diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 3900f8d7..0be5da3d 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import getPort from 'get-port'; import { getOpenAICompatProxyStatus, + listOpenAICompatProxyStatuses, startOpenAICompatProxy, stopOpenAICompatProxy, } from '../../../src/proxy/proxy-daemon'; @@ -12,6 +13,7 @@ import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-rou import { getLegacyOpenAICompatProxyPidPath, getLegacyOpenAICompatProxySessionPath, + getOpenAICompatProxySessionPath, } from '../../../src/proxy/proxy-daemon-paths'; import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; @@ -352,4 +354,114 @@ describe('openai proxy daemon lifecycle', () => { busyServer.stop(true); } }); + + it('reuses the last-known port even when it is outside the default fallback range', async () => { + const preferredPort = await getPort(); + const settingsPath = path.join(tempDir, 'outside-range.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-outside-range', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('outside-range', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-outside-range', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected an outside-range OpenAI-compatible profile'); + } + + fs.mkdirSync(path.dirname(getOpenAICompatProxySessionPath('outside-range')), { recursive: true }); + fs.writeFileSync( + getOpenAICompatProxySessionPath('outside-range'), + JSON.stringify( + { + profileName: profile.profileName, + settingsPath: profile.settingsPath, + host: '127.0.0.1', + port: preferredPort, + baseUrl: profile.baseUrl, + authToken: 'stale-token', + model: profile.model, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const started = await startOpenAICompatProxy(profile); + expect(started.success).toBe(true); + expect(started.port).toBe(preferredPort); + }); + + it('stops legacy daemons even when the legacy session is missing a profile name', async () => { + const port = await getPort(); + const settingsPath = path.join(tempDir, 'legacy-missing-profile.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-legacy-missing-profile', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('legacy-missing-profile', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-legacy-missing-profile', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected a legacy fallback OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile, { port }); + expect(started.success).toBe(true); + expect(started.pid).toBeDefined(); + + const proxyDir = path.dirname(getLegacyOpenAICompatProxyPidPath()); + fs.writeFileSync(getLegacyOpenAICompatProxyPidPath(), String(started.pid), 'utf8'); + fs.writeFileSync( + getLegacyOpenAICompatProxySessionPath(), + JSON.stringify( + { + settingsPath: profile.settingsPath, + host: '127.0.0.1', + port, + baseUrl: profile.baseUrl, + authToken: started.authToken, + model: profile.model, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + fs.rmSync(path.join(proxyDir, 'legacy-missing-profile.daemon.pid'), { force: true }); + fs.rmSync(path.join(proxyDir, 'legacy-missing-profile.session.json'), { force: true }); + + const statuses = await listOpenAICompatProxyStatuses(); + expect(statuses.some((status) => status.port === port)).toBe(true); + + const stopped = await stopOpenAICompatProxy(); + expect(stopped.success).toBe(true); + expect(fs.existsSync(getLegacyOpenAICompatProxyPidPath())).toBe(false); + expect(fs.existsSync(getLegacyOpenAICompatProxySessionPath())).toBe(false); + }, 35000); });