mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 10:19:37 +00:00
fix(proxy): handle legacy stop and preferred ports
This commit is contained in:
+106
-12
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user