fix(proxy): handle legacy stop and preferred ports

This commit is contained in:
Wooseong Kim
2026-04-20 15:25:16 +09:00
parent 54c3b2d40b
commit 391bdddc22
2 changed files with 218 additions and 12 deletions
+106 -12
View File
@@ -115,11 +115,7 @@ function listOpenAICompatProxyCandidatePorts(
} }
const candidates = new Set<number>(); const candidates = new Set<number>();
if ( if (!excludedPorts.has(preferredPort)) {
preferredPort >= OPENAI_COMPAT_PROXY_DEFAULT_PORT &&
preferredPort <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10 &&
!excludedPorts.has(preferredPort)
) {
candidates.add(preferredPort); candidates.add(preferredPort);
} }
@@ -215,6 +211,22 @@ async function getOpenAICompatProxyStatusForProfile(
}; };
} }
async function getLegacyOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus | null> {
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 { function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatProxyStateRecord {
const session = readOpenAICompatProxySession(profileName); const session = readOpenAICompatProxySession(profileName);
if (session) { if (session) {
@@ -243,10 +255,17 @@ export async function listOpenAICompatProxyStatuses(): Promise<OpenAICompatProxy
if (legacySession?.profileName) { if (legacySession?.profileName) {
profileNames.add(legacySession.profileName); profileNames.add(legacySession.profileName);
} }
const statuses = await Promise.all( const profileStatuses = await Promise.all(
[...profileNames].map((profileName) => getOpenAICompatProxyStatusForProfile(profileName)) [...profileNames].map((profileName) => 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( export async function getOpenAICompatProxyStatus(
@@ -333,6 +352,74 @@ async function stopOpenAICompatProxyUnlocked(
return { success: true }; 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( function removeOpenAICompatProxyState(
state: OpenAICompatProxyStateRecord, state: OpenAICompatProxyStateRecord,
profileName: string profileName: string
@@ -356,15 +443,22 @@ export async function stopOpenAICompatProxy(
} }
const statuses = await listOpenAICompatProxyStatuses(); const statuses = await listOpenAICompatProxyStatuses();
const failures: string[] = [];
for (const status of statuses) { for (const status of statuses) {
if (!status.profileName) { const stopped = status.profileName
continue; ? await stopOpenAICompatProxyUnlocked(status.profileName)
} : await stopLegacyOpenAICompatProxyUnlocked();
const stopped = await stopOpenAICompatProxyUnlocked(status.profileName);
if (!stopped.success) { 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 }; return { success: true };
}); });
} }
@@ -5,6 +5,7 @@ import * as path from 'path';
import getPort from 'get-port'; import getPort from 'get-port';
import { import {
getOpenAICompatProxyStatus, getOpenAICompatProxyStatus,
listOpenAICompatProxyStatuses,
startOpenAICompatProxy, startOpenAICompatProxy,
stopOpenAICompatProxy, stopOpenAICompatProxy,
} from '../../../src/proxy/proxy-daemon'; } from '../../../src/proxy/proxy-daemon';
@@ -12,6 +13,7 @@ import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-rou
import { import {
getLegacyOpenAICompatProxyPidPath, getLegacyOpenAICompatProxyPidPath,
getLegacyOpenAICompatProxySessionPath, getLegacyOpenAICompatProxySessionPath,
getOpenAICompatProxySessionPath,
} from '../../../src/proxy/proxy-daemon-paths'; } from '../../../src/proxy/proxy-daemon-paths';
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
@@ -352,4 +354,114 @@ describe('openai proxy daemon lifecycle', () => {
busyServer.stop(true); 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);
}); });