fix(proxy): preserve running daemons on restart failure

This commit is contained in:
Wooseong Kim
2026-04-20 14:51:32 +09:00
parent dfd070c9c6
commit db32d15d86
4 changed files with 233 additions and 56 deletions
+39 -13
View File
@@ -133,29 +133,55 @@ async function handleStatus(args: string[] = []): Promise<number> {
return showHelp();
}
const profileName = findPositionalArg(args);
const running = profileName
? [await getOpenAICompatProxyStatus(profileName)].filter((status) => status.running)
: (await listOpenAICompatProxyStatuses()).filter((status) => status.running);
if (running.length === 0) {
console.log(info('Proxy is not running'));
return 0;
}
for (const status of running) {
console.log(ok(`Proxy running on port ${status.port}`));
if (status.host) {
const printStatus = (status: Awaited<ReturnType<typeof getOpenAICompatProxyStatus>>) => {
console.log(
status.running
? ok(`Proxy running on port ${status.port}`)
: info(`Proxy is not running${status.port ? ` (last known port ${status.port})` : ''}`)
);
if (status.host && status.port) {
console.log(` Host: ${status.host}`);
console.log(` Local URL: http://${status.host}:${status.port}`);
}
console.log(` Profile: ${status.profileName}`);
console.log(` Base URL: ${status.baseUrl}`);
if (status.profileName) {
console.log(` Profile: ${status.profileName}`);
}
if (status.baseUrl) {
console.log(` Base URL: ${status.baseUrl}`);
}
if (status.model) {
console.log(` Model: ${status.model}`);
}
if (status.pid) {
console.log(` PID: ${status.pid}`);
}
};
if (profileName) {
const status = await getOpenAICompatProxyStatus(profileName);
if (!status.running && !status.profileName) {
console.log(info('Proxy is not running'));
return 0;
}
printStatus(status);
return 0;
}
const status = await getOpenAICompatProxyStatus();
if (!status.running && !status.profileName) {
console.log(info('Proxy is not running'));
return 0;
}
if (status.running && !status.profileName) {
const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running);
for (const entry of running) {
printStatus(entry);
}
return 0;
}
printStatus(status);
return 0;
}
+117 -43
View File
@@ -42,6 +42,11 @@ export interface StartOpenAICompatProxyResult {
error?: string;
}
interface OpenAICompatProxyLaunchResult extends StartOpenAICompatProxyResult {
commitState?: () => void;
stop?: () => Promise<void>;
}
function generateProxyAuthToken(): string {
return crypto.randomBytes(24).toString('hex');
}
@@ -93,6 +98,30 @@ async function isPortOccupied(port: number): Promise<boolean> {
});
}
async function terminateDaemonProcess(pid?: number): Promise<void> {
if (!pid) {
return;
}
try {
process.kill(pid, 'SIGTERM');
let attempts = 0;
while (attempts < 10) {
await new Promise((resolve) => setTimeout(resolve, 200));
try {
process.kill(pid, 0);
attempts += 1;
} catch {
return;
}
}
process.kill(pid, 'SIGKILL');
} catch {
// Best-effort cleanup for a daemon we just spawned.
}
}
async function findOpenAICompatProxyPort(
excludedPorts: ReadonlySet<number> = new Set()
): Promise<number> {
@@ -397,17 +426,35 @@ export async function startOpenAICompatProxy(
};
}
const startOnPort = (port: number): Promise<StartOpenAICompatProxyResult> =>
const launchOnPort = (
port: number,
persistState: boolean
): Promise<OpenAICompatProxyLaunchResult> =>
new Promise((resolve) => {
let resolved = false;
let timeout: NodeJS.Timeout | null = null;
const authToken = generateProxyAuthToken();
const commitState = () => {
if (proc.pid) {
writeOpenAICompatProxyPid(profile.profileName, proc.pid);
}
writeOpenAICompatProxySession({
profileName: profile.profileName,
settingsPath: profile.settingsPath,
host,
port,
baseUrl: profile.baseUrl,
authToken,
model: profile.model,
insecure: options.insecure,
});
};
const finish = (result: StartOpenAICompatProxyResult) => {
const finish = (result: OpenAICompatProxyLaunchResult) => {
if (resolved) return;
resolved = true;
if (timeout) clearTimeout(timeout);
if (!result.success) {
if (!result.success && persistState) {
removeOpenAICompatProxyPid(profile.profileName);
removeOpenAICompatProxySession(profile.profileName);
}
@@ -435,25 +482,28 @@ export async function startOpenAICompatProxy(
);
proc.unref();
if (proc.pid) {
writeOpenAICompatProxyPid(profile.profileName, proc.pid);
if (persistState) {
commitState();
}
writeOpenAICompatProxySession({
profileName: profile.profileName,
settingsPath: profile.settingsPath,
host,
port,
baseUrl: profile.baseUrl,
authToken,
model: profile.model,
insecure: options.insecure,
});
let attempts = 0;
const poll = async () => {
attempts += 1;
if (await isOpenAICompatProxyRunning(port)) {
finish({ success: true, pid: proc.pid, port, authToken });
finish({
success: true,
pid: proc.pid,
port,
authToken,
...(persistState
? {}
: {
commitState,
stop: async () => {
await terminateDaemonProcess(proc.pid);
},
}),
});
return;
}
if (attempts >= 30) {
@@ -496,40 +546,64 @@ export async function startOpenAICompatProxy(
});
});
if (requiresExactPort) {
return startOnPort(preferredPort);
}
const launchProxy = async (persistState: boolean): Promise<OpenAICompatProxyLaunchResult> => {
if (requiresExactPort) {
return launchOnPort(preferredPort, persistState);
}
const attemptedPorts = new Set<number>();
let lastResult: StartOpenAICompatProxyResult | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
const port = await findOpenAICompatProxyPortNear(preferredPort, attemptedPorts);
if (port === 0) {
return {
const attemptedPorts = new Set<number>();
let lastResult: OpenAICompatProxyLaunchResult | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
const port = await findOpenAICompatProxyPortNear(preferredPort, attemptedPorts);
if (port === 0) {
return {
success: false,
port: preferredPort,
error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`,
};
}
const result = await launchOnPort(port, persistState);
if (result.success) {
return result;
}
lastResult = result;
attemptedPorts.add(port);
if (!(await isPortOccupied(port))) {
return result;
}
}
return (
lastResult ?? {
success: false,
port: preferredPort,
error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`,
error: 'Failed to start proxy',
}
);
};
if (status.running) {
const launched = await launchProxy(false);
if (!launched.success) {
return launched;
}
const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName);
if (!stopped.success) {
await launched.stop?.();
return {
success: false,
port: launched.port,
error: stopped.error || 'Failed to replace the running proxy',
};
}
const result = await startOnPort(port);
if (result.success) {
return result;
}
lastResult = result;
attemptedPorts.add(port);
if (!(await isPortOccupied(port))) {
return result;
}
launched.commitState?.();
return launched;
}
return (
lastResult ?? {
success: false,
port: preferredPort,
error: 'Failed to start proxy',
}
);
return launchProxy(true);
});
}
+28
View File
@@ -52,6 +52,34 @@ describe('proxy command e2e', () => {
expect(help.stdout).toContain('stop [profile] Stop the running proxy (or all proxies when omitted)');
});
it('shows the last-known proxy state when no proxy is currently running', async () => {
const stalePort = await getPort();
const proxyDir = path.join(tempDir, '.ccs', 'proxy');
fs.mkdirSync(proxyDir, { recursive: true });
fs.writeFileSync(
path.join(proxyDir, 'stale.session.json'),
JSON.stringify(
{
profileName: 'stale',
settingsPath: path.join(tempDir, '.ccs', 'stale.settings.json'),
host: '127.0.0.1',
port: stalePort,
baseUrl: 'http://127.0.0.1:11434',
authToken: 'deadbeef',
model: 'qwen3-coder',
},
null,
2
) + '\n',
'utf8'
);
const status = runCli(['proxy', 'status']);
expect(status.status).toBe(0);
expect(status.stdout).toContain(`Proxy is not running (last known port ${stalePort})`);
expect(status.stdout).toContain('Profile: stale');
});
it('starts, reports status, activates, and stops via the built CLI', async () => {
const port = await getPort();
const ccsDir = path.join(tempDir, '.ccs');
@@ -303,4 +303,53 @@ describe('openai proxy daemon lifecycle', () => {
server.stop(true);
}
});
it('keeps the existing proxy running if replacement startup fails', async () => {
const firstPort = await getPort();
const occupiedPort = await getPort();
const busyServer = Bun.serve({
port: occupiedPort,
hostname: '127.0.0.1',
fetch: () => new Response('busy'),
});
try {
const settingsPath = path.join(tempDir, 'rollback.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
ANTHROPIC_AUTH_TOKEN: 'ollama-rollback',
ANTHROPIC_MODEL: 'qwen3-coder',
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
},
}),
'utf8'
);
const profile = resolveOpenAICompatProfileConfig('rollback', settingsPath, {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
ANTHROPIC_AUTH_TOKEN: 'ollama-rollback',
ANTHROPIC_MODEL: 'qwen3-coder',
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
});
if (!profile) {
throw new Error('Expected a rollback OpenAI-compatible profile');
}
const firstStart = await startOpenAICompatProxy(profile, { port: firstPort });
expect(firstStart.success).toBe(true);
const restarted = await startOpenAICompatProxy(profile, { port: occupiedPort });
expect(restarted.success).toBe(false);
const status = await getOpenAICompatProxyStatus('rollback');
expect(status.running).toBe(true);
expect(status.port).toBe(firstPort);
expect((await fetch(`http://127.0.0.1:${firstPort}/health`)).status).toBe(200);
} finally {
busyServer.stop(true);
}
});
});