diff --git a/src/proxy/proxy-daemon-paths.ts b/src/proxy/proxy-daemon-paths.ts index 011a9f59..7119c2e9 100644 --- a/src/proxy/proxy-daemon-paths.ts +++ b/src/proxy/proxy-daemon-paths.ts @@ -25,3 +25,11 @@ export function getOpenAICompatProxySessionPath(profileName: string): string { `${getOpenAICompatProxyProfileKey(profileName)}.session.json` ); } + +export function getLegacyOpenAICompatProxyPidPath(): string { + return path.join(getOpenAICompatProxyDir(), 'daemon.pid'); +} + +export function getLegacyOpenAICompatProxySessionPath(): string { + return path.join(getOpenAICompatProxyDir(), 'session.json'); +} diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts index 82d11130..98899e47 100644 --- a/src/proxy/proxy-daemon-state.ts +++ b/src/proxy/proxy-daemon-state.ts @@ -2,6 +2,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { getOpenAICompatProxyDir, + getLegacyOpenAICompatProxyPidPath, + getLegacyOpenAICompatProxySessionPath, getOpenAICompatProxyPidPath, getOpenAICompatProxySessionPath, } from './proxy-daemon-paths'; @@ -21,9 +23,9 @@ function ensureProxyDir(): void { fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true }); } -export function getOpenAICompatProxyPid(profileName: string): number | null { +function readPid(pidPath: string): number | null { try { - const raw = fs.readFileSync(getOpenAICompatProxyPidPath(profileName), 'utf8').trim(); + const raw = fs.readFileSync(pidPath, 'utf8').trim(); const pid = Number.parseInt(raw, 10); return Number.isInteger(pid) ? pid : null; } catch { @@ -31,6 +33,14 @@ export function getOpenAICompatProxyPid(profileName: string): number | null { } } +export function getOpenAICompatProxyPid(profileName: string): number | null { + return readPid(getOpenAICompatProxyPidPath(profileName)); +} + +export function getLegacyOpenAICompatProxyPid(): number | null { + return readPid(getLegacyOpenAICompatProxyPidPath()); +} + export function writeOpenAICompatProxyPid(profileName: string, pid: number): void { ensureProxyDir(); fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8'); @@ -44,16 +54,30 @@ export function removeOpenAICompatProxyPid(profileName: string): void { } } -export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null { +export function removeLegacyOpenAICompatProxyPid(): void { try { - return JSON.parse( - fs.readFileSync(getOpenAICompatProxySessionPath(profileName), 'utf8') - ) as OpenAICompatProxySession; + fs.unlinkSync(getLegacyOpenAICompatProxyPidPath()); + } catch { + // Best-effort cleanup. + } +} + +function readSession(sessionPath: string): OpenAICompatProxySession | null { + try { + return JSON.parse(fs.readFileSync(sessionPath, 'utf8')) as OpenAICompatProxySession; } catch { return null; } } +export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null { + return readSession(getOpenAICompatProxySessionPath(profileName)); +} + +export function readLegacyOpenAICompatProxySession(): OpenAICompatProxySession | null { + return readSession(getLegacyOpenAICompatProxySessionPath()); +} + export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void { ensureProxyDir(); fs.writeFileSync( @@ -71,6 +95,14 @@ export function removeOpenAICompatProxySession(profileName: string): void { } } +export function removeLegacyOpenAICompatProxySession(): void { + try { + fs.unlinkSync(getLegacyOpenAICompatProxySessionPath()); + } catch { + // Best-effort cleanup. + } +} + export function listOpenAICompatProxyProfileNames(): string[] { try { const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true }); @@ -87,18 +119,6 @@ export function listOpenAICompatProxyProfileNames(): string[] { } } -export function cleanupLegacyOpenAICompatProxyState(): void { - ensureProxyDir(); - const legacyNames = ['daemon.pid', 'session.json']; - for (const legacyName of legacyNames) { - try { - fs.unlinkSync(path.join(getOpenAICompatProxyDir(), legacyName)); - } catch { - // Best-effort cleanup. - } - } -} - export function resolveOpenAICompatProxyEntrypointCandidates(): string[] { const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js'); const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts'); diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index ac3f3599..d26c8e3c 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -12,10 +12,13 @@ import { getOpenAICompatProxyDir, } from './proxy-daemon-paths'; import { + getLegacyOpenAICompatProxyPid, getOpenAICompatProxyPid, listOpenAICompatProxyProfileNames, + readLegacyOpenAICompatProxySession, readOpenAICompatProxySession, - cleanupLegacyOpenAICompatProxyState, + removeLegacyOpenAICompatProxyPid, + removeLegacyOpenAICompatProxySession, removeOpenAICompatProxyPid, removeOpenAICompatProxySession, resolveOpenAICompatProxyEntrypointCandidates, @@ -23,7 +26,7 @@ import { writeOpenAICompatProxyPid, writeOpenAICompatProxySession, } from './proxy-daemon-state'; -import { resolveOpenAICompatProxyPreferredPort } from './proxy-port-resolver'; +import { resolveOpenAICompatProxyPortPreference } from './proxy-port-resolver'; export interface OpenAICompatProxyStatus extends Partial { running: boolean; @@ -111,6 +114,12 @@ async function findOpenAICompatProxyPortNear(preferredPort: number): Promise { for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) { try { @@ -157,20 +166,51 @@ export async function isOpenAICompatProxyRunning(port: number): Promise async function getOpenAICompatProxyStatusForProfile( profileName: string ): Promise { - const session = readOpenAICompatProxySession(profileName); - const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; + const state = getOpenAICompatProxyStateForProfile(profileName); + const session = state.session; + if (!session) { + return { running: false }; + } + + const port = session.port; const running = await isOpenAICompatProxyRunning(port); return { running, - pid: running ? getOpenAICompatProxyPid(profileName) || undefined : undefined, + pid: running ? state.pid || undefined : undefined, ...session, }; } +function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatProxyStateRecord { + const session = readOpenAICompatProxySession(profileName); + if (session) { + return { + pid: getOpenAICompatProxyPid(profileName), + session, + source: 'profile', + }; + } + + const legacySession = readLegacyOpenAICompatProxySession(); + if (legacySession?.profileName === profileName) { + return { + pid: getLegacyOpenAICompatProxyPid(), + session: legacySession, + source: 'legacy', + }; + } + + return { pid: null, session: null, source: 'profile' }; +} + export async function listOpenAICompatProxyStatuses(): Promise { - const profileNames = listOpenAICompatProxyProfileNames(); + const profileNames = new Set(listOpenAICompatProxyProfileNames()); + const legacySession = readLegacyOpenAICompatProxySession(); + if (legacySession?.profileName) { + profileNames.add(legacySession.profileName); + } const statuses = await Promise.all( - profileNames.map((profileName) => getOpenAICompatProxyStatusForProfile(profileName)) + [...profileNames].map((profileName) => getOpenAICompatProxyStatusForProfile(profileName)) ); return statuses.filter((status) => status.profileName); } @@ -197,9 +237,10 @@ export async function getOpenAICompatProxyStatus( async function stopOpenAICompatProxyUnlocked( profileName: string ): Promise<{ success: boolean; error?: string }> { - const pid = getOpenAICompatProxyPid(profileName); + const state = getOpenAICompatProxyStateForProfile(profileName); + const pid = state.pid; if (!pid) { - removeOpenAICompatProxySession(profileName); + removeOpenAICompatProxyState(state, profileName); return { success: true }; } @@ -211,8 +252,7 @@ async function stopOpenAICompatProxyUnlocked( ); if (ownership === 'not-owned') { - removeOpenAICompatProxyPid(profileName); - removeOpenAICompatProxySession(profileName); + removeOpenAICompatProxyState(state, profileName); return { success: true }; } @@ -224,8 +264,7 @@ async function stopOpenAICompatProxyUnlocked( } if (ownership === 'not-running') { - removeOpenAICompatProxyPid(profileName); - removeOpenAICompatProxySession(profileName); + removeOpenAICompatProxyState(state, profileName); return { success: true }; } @@ -256,16 +295,28 @@ async function stopOpenAICompatProxyUnlocked( } } + removeOpenAICompatProxyState(state, profileName); + return { success: true }; +} + +function removeOpenAICompatProxyState( + state: OpenAICompatProxyStateRecord, + profileName: string +): void { + if (state.source === 'legacy') { + removeLegacyOpenAICompatProxyPid(); + removeLegacyOpenAICompatProxySession(); + return; + } + removeOpenAICompatProxyPid(profileName); removeOpenAICompatProxySession(profileName); - return { success: true }; } export async function stopOpenAICompatProxy( profileName?: string ): Promise<{ success: boolean; error?: string }> { return withOpenAICompatProxyLock(async () => { - cleanupLegacyOpenAICompatProxyState(); if (profileName) { return stopOpenAICompatProxyUnlocked(profileName); } @@ -289,17 +340,22 @@ export async function startOpenAICompatProxy( options: { port?: number; host?: string; insecure?: boolean } = {} ): Promise { return withOpenAICompatProxyLock(async () => { - cleanupLegacyOpenAICompatProxyState(); const status = await getOpenAICompatProxyStatus(profile.profileName); const host = options.host?.trim() || status.host || '127.0.0.1'; + const portPreference = resolveOpenAICompatProxyPortPreference(profile.profileName); + const explicitPort = typeof options.port === 'number' ? options.port : undefined; const preferredPort = - typeof options.port === 'number' - ? options.port - : status.port || resolveOpenAICompatProxyPreferredPort(profile.profileName); + explicitPort ?? + (portPreference.source === 'profile' + ? portPreference.port + : status.port || portPreference.port); + const requiresExactPort = explicitPort !== undefined || portPreference.source === 'profile'; const port = status.running && status.port && (status.host || '127.0.0.1') === host ? status.port - : await findOpenAICompatProxyPortNear(preferredPort); + : requiresExactPort + ? preferredPort + : await findOpenAICompatProxyPortNear(preferredPort); if (port === 0) { return { success: false, @@ -319,6 +375,13 @@ export async function startOpenAICompatProxy( authToken: status.authToken, }; } + if (requiresExactPort && (await isPortOccupied(port))) { + return { + success: false, + port, + error: `Requested proxy port ${port} is already in use`, + }; + } if (status.running) { const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName); if (!stopped.success) { diff --git a/src/proxy/proxy-port-resolver.ts b/src/proxy/proxy-port-resolver.ts index 919e5755..418ca721 100644 --- a/src/proxy/proxy-port-resolver.ts +++ b/src/proxy/proxy-port-resolver.ts @@ -1,11 +1,25 @@ import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths'; -export function resolveOpenAICompatProxyPreferredPort(profileName: string): number { +export interface OpenAICompatProxyPortPreference { + port: number; + source: 'default' | 'profile'; +} + +export function resolveOpenAICompatProxyPortPreference( + profileName: string +): OpenAICompatProxyPortPreference { const config = loadOrCreateUnifiedConfig(); const profilePort = config.proxy?.profile_ports?.[profileName]; if (typeof profilePort === 'number') { - return profilePort; + return { port: profilePort, source: 'profile' }; } - return config.proxy?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; + return { + port: config.proxy?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT, + source: 'default', + }; +} + +export function resolveOpenAICompatProxyPreferredPort(profileName: string): number { + return resolveOpenAICompatProxyPortPreference(profileName).port; } diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index f6cb8e63..9ff9e5f6 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -9,6 +9,11 @@ import { stopOpenAICompatProxy, } from '../../../src/proxy/proxy-daemon'; import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; +import { + getLegacyOpenAICompatProxyPidPath, + getLegacyOpenAICompatProxySessionPath, +} from '../../../src/proxy/proxy-daemon-paths'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; let originalCcsHome: string | undefined; let tempDir: string; @@ -140,4 +145,162 @@ describe('openai proxy daemon lifecycle', () => { const secondHealth = await fetch(`http://127.0.0.1:${secondPort}/health`); expect(secondHealth.status).toBe(200); }); + + it('keeps a legacy singleton daemon visible across upgrade', async () => { + const port = await getPort(); + const settingsPath = path.join(tempDir, 'legacy.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-legacy', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('legacy', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-legacy', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected a legacy 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( + { + profileName: profile.profileName, + 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.daemon.pid'), { force: true }); + fs.rmSync(path.join(proxyDir, 'legacy.session.json'), { force: true }); + + const status = await getOpenAICompatProxyStatus('legacy'); + expect(status.running).toBe(true); + expect(status.port).toBe(port); + + const restarted = await startOpenAICompatProxy(profile); + expect(restarted.success).toBe(true); + expect(restarted.alreadyRunning).toBe(true); + expect(restarted.port).toBe(port); + + const stopped = await stopOpenAICompatProxy('legacy'); + expect(stopped.success).toBe(true); + expect(fs.existsSync(getLegacyOpenAICompatProxyPidPath())).toBe(false); + expect(fs.existsSync(getLegacyOpenAICompatProxySessionPath())).toBe(false); + }, 35000); + + it('fails when an explicit port is already occupied', async () => { + const occupiedPort = await getPort(); + const server = Bun.serve({ + port: occupiedPort, + hostname: '127.0.0.1', + fetch: () => new Response('busy'), + }); + + try { + const settingsPath = path.join(tempDir, 'occupied-explicit.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-explicit', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('explicit', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-explicit', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected an explicit-port OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile, { port: occupiedPort }); + expect(started.success).toBe(false); + expect(started.port).toBe(occupiedPort); + expect(started.error).toContain(`Requested proxy port ${occupiedPort} is already in use`); + } finally { + server.stop(true); + } + }); + + it('fails when a configured profile port is already occupied', async () => { + const occupiedPort = await getPort(); + const server = Bun.serve({ + port: occupiedPort, + hostname: '127.0.0.1', + fetch: () => new Response('busy'), + }); + + try { + mutateUnifiedConfig((config) => { + config.proxy = { + ...(config.proxy ?? {}), + profile_ports: { mapped: occupiedPort }, + }; + }); + + const settingsPath = path.join(tempDir, 'occupied-mapped.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-mapped', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('mapped', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-mapped', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected a mapped-port OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile); + expect(started.success).toBe(false); + expect(started.port).toBe(occupiedPort); + expect(started.error).toContain(`Requested proxy port ${occupiedPort} is already in use`); + } finally { + server.stop(true); + } + }); });