fix(proxy): preserve legacy state and exact ports

This commit is contained in:
Wooseong Kim
2026-04-20 14:03:12 +09:00
parent 630a493cca
commit 24c24847f7
5 changed files with 309 additions and 41 deletions
+8
View File
@@ -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');
}
+38 -18
View File
@@ -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');
+83 -20
View File
@@ -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<OpenAICompatProxySession> {
running: boolean;
@@ -111,6 +114,12 @@ async function findOpenAICompatProxyPortNear(preferredPort: number): Promise<num
return findOpenAICompatProxyPort();
}
interface OpenAICompatProxyStateRecord {
pid: number | null;
session: OpenAICompatProxySession | null;
source: 'profile' | 'legacy';
}
async function resolveDaemonEntrypoint(): Promise<string | null> {
for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) {
try {
@@ -157,20 +166,51 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
async function getOpenAICompatProxyStatusForProfile(
profileName: string
): Promise<OpenAICompatProxyStatus> {
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<OpenAICompatProxyStatus[]> {
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<StartOpenAICompatProxyResult> {
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) {
+17 -3
View File
@@ -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;
}
@@ -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);
}
});
});