mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
fix(proxy): retry raced dynamic port binds
This commit is contained in:
@@ -199,7 +199,7 @@ export async function handleProxyCommand(args: string[]): Promise<number> {
|
|||||||
case 'start':
|
case 'start':
|
||||||
return handleStart(args.slice(1));
|
return handleStart(args.slice(1));
|
||||||
case 'stop': {
|
case 'stop': {
|
||||||
const profileName = args[1] && !args[1]?.startsWith('-') ? args[1] : undefined;
|
const profileName = args[1] && !args[1].startsWith('-') ? args[1] : undefined;
|
||||||
const result = await stopOpenAICompatProxy(profileName);
|
const result = await stopOpenAICompatProxy(profileName);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error(fail(result.error || 'Failed to stop proxy'));
|
console.error(fail(result.error || 'Failed to stop proxy'));
|
||||||
|
|||||||
+150
-118
@@ -93,12 +93,17 @@ async function isPortOccupied(port: number): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findOpenAICompatProxyPort(): Promise<number> {
|
async function findOpenAICompatProxyPort(
|
||||||
|
excludedPorts: ReadonlySet<number> = new Set()
|
||||||
|
): Promise<number> {
|
||||||
for (
|
for (
|
||||||
let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||||
candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10;
|
candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10;
|
||||||
candidate += 1
|
candidate += 1
|
||||||
) {
|
) {
|
||||||
|
if (excludedPorts.has(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!(await isPortOccupied(candidate))) {
|
if (!(await isPortOccupied(candidate))) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
@@ -107,11 +112,14 @@ async function findOpenAICompatProxyPort(): Promise<number> {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findOpenAICompatProxyPortNear(preferredPort: number): Promise<number> {
|
async function findOpenAICompatProxyPortNear(
|
||||||
if (!(await isPortOccupied(preferredPort))) {
|
preferredPort: number,
|
||||||
|
excludedPorts: ReadonlySet<number> = new Set()
|
||||||
|
): Promise<number> {
|
||||||
|
if (!excludedPorts.has(preferredPort) && !(await isPortOccupied(preferredPort))) {
|
||||||
return preferredPort;
|
return preferredPort;
|
||||||
}
|
}
|
||||||
return findOpenAICompatProxyPort();
|
return findOpenAICompatProxyPort(excludedPorts);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenAICompatProxyStateRecord {
|
interface OpenAICompatProxyStateRecord {
|
||||||
@@ -350,36 +358,23 @@ export async function startOpenAICompatProxy(
|
|||||||
? portPreference.port
|
? portPreference.port
|
||||||
: status.port || portPreference.port);
|
: status.port || portPreference.port);
|
||||||
const requiresExactPort = explicitPort !== undefined || portPreference.source === 'profile';
|
const requiresExactPort = explicitPort !== undefined || portPreference.source === 'profile';
|
||||||
const port =
|
if (status.running && status.port === preferredPort && (status.host || '127.0.0.1') === host) {
|
||||||
status.running && status.port && (status.host || '127.0.0.1') === host
|
|
||||||
? status.port
|
|
||||||
: requiresExactPort
|
|
||||||
? preferredPort
|
|
||||||
: await findOpenAICompatProxyPortNear(preferredPort);
|
|
||||||
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}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
||||||
return { success: false, port, error: `Invalid port: ${port}` };
|
|
||||||
}
|
|
||||||
if (status.running && status.port === port && (status.host || '127.0.0.1') === host) {
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
alreadyRunning: true,
|
alreadyRunning: true,
|
||||||
pid: status.pid,
|
pid: status.pid,
|
||||||
port,
|
port: preferredPort,
|
||||||
authToken: status.authToken,
|
authToken: status.authToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (requiresExactPort && (await isPortOccupied(port))) {
|
if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) {
|
||||||
|
return { success: false, port: preferredPort, error: `Invalid port: ${preferredPort}` };
|
||||||
|
}
|
||||||
|
if (requiresExactPort && (await isPortOccupied(preferredPort))) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
port,
|
port: preferredPort,
|
||||||
error: `Requested proxy port ${port} is already in use`,
|
error: `Requested proxy port ${preferredPort} is already in use`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (status.running) {
|
if (status.running) {
|
||||||
@@ -387,7 +382,7 @@ export async function startOpenAICompatProxy(
|
|||||||
if (!stopped.success) {
|
if (!stopped.success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
port,
|
port: preferredPort,
|
||||||
error: stopped.error || 'Failed to restart the running proxy',
|
error: stopped.error || 'Failed to restart the running proxy',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -397,107 +392,144 @@ export async function startOpenAICompatProxy(
|
|||||||
if (!daemonEntry) {
|
if (!daemonEntry) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
port,
|
port: preferredPort,
|
||||||
error: 'OpenAI proxy daemon entrypoint not found. Run `bun run build` and retry.',
|
error: 'OpenAI proxy daemon entrypoint not found. Run `bun run build` and retry.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
const startOnPort = (port: number): Promise<StartOpenAICompatProxyResult> =>
|
||||||
let resolved = false;
|
new Promise((resolve) => {
|
||||||
let timeout: NodeJS.Timeout | null = null;
|
let resolved = false;
|
||||||
const authToken = generateProxyAuthToken();
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
const authToken = generateProxyAuthToken();
|
||||||
|
|
||||||
const finish = (result: StartOpenAICompatProxyResult) => {
|
const finish = (result: StartOpenAICompatProxyResult) => {
|
||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
resolved = true;
|
resolved = true;
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
removeOpenAICompatProxyPid(profile.profileName);
|
removeOpenAICompatProxyPid(profile.profileName);
|
||||||
removeOpenAICompatProxySession(profile.profileName);
|
removeOpenAICompatProxySession(profile.profileName);
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const proc: ChildProcess = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
daemonEntry,
|
||||||
|
'--port',
|
||||||
|
String(port),
|
||||||
|
'--host',
|
||||||
|
host,
|
||||||
|
'--profile',
|
||||||
|
profile.profileName,
|
||||||
|
'--settings-path',
|
||||||
|
profile.settingsPath,
|
||||||
|
'--auth-token',
|
||||||
|
authToken,
|
||||||
|
...(options.insecure ? ['--insecure'] : []),
|
||||||
|
'--ccs-openai-proxy-daemon',
|
||||||
|
],
|
||||||
|
{ stdio: 'ignore', detached: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
proc.unref();
|
||||||
|
if (proc.pid) {
|
||||||
|
writeOpenAICompatProxyPid(profile.profileName, proc.pid);
|
||||||
}
|
}
|
||||||
resolve(result);
|
writeOpenAICompatProxySession({
|
||||||
};
|
profileName: profile.profileName,
|
||||||
|
settingsPath: profile.settingsPath,
|
||||||
const proc: ChildProcess = spawn(
|
|
||||||
process.execPath,
|
|
||||||
[
|
|
||||||
daemonEntry,
|
|
||||||
'--port',
|
|
||||||
String(port),
|
|
||||||
'--host',
|
|
||||||
host,
|
host,
|
||||||
'--profile',
|
|
||||||
profile.profileName,
|
|
||||||
'--settings-path',
|
|
||||||
profile.settingsPath,
|
|
||||||
'--auth-token',
|
|
||||||
authToken,
|
|
||||||
...(options.insecure ? ['--insecure'] : []),
|
|
||||||
'--ccs-openai-proxy-daemon',
|
|
||||||
],
|
|
||||||
{ stdio: 'ignore', detached: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
proc.unref();
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
let attempts = 0;
|
|
||||||
const poll = async () => {
|
|
||||||
attempts += 1;
|
|
||||||
if (await isOpenAICompatProxyRunning(port)) {
|
|
||||||
finish({ success: true, pid: proc.pid, port, authToken });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (attempts >= 30) {
|
|
||||||
finish({
|
|
||||||
success: false,
|
|
||||||
port,
|
|
||||||
error: `Proxy daemon did not start within 30 seconds on port ${port}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
timeout = setTimeout(poll, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
timeout = setTimeout(poll, 1000);
|
|
||||||
proc.on('error', (error) => {
|
|
||||||
finish({ success: false, port, error: error.message });
|
|
||||||
});
|
|
||||||
proc.on('exit', (code, signal) => {
|
|
||||||
if (code === 0) {
|
|
||||||
finish({
|
|
||||||
success: false,
|
|
||||||
port,
|
|
||||||
error: 'Proxy daemon exited before becoming healthy',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (code !== null) {
|
|
||||||
finish({
|
|
||||||
success: false,
|
|
||||||
port,
|
|
||||||
error: `Proxy daemon exited with code ${code}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finish({
|
|
||||||
success: false,
|
|
||||||
port,
|
port,
|
||||||
error: `Proxy daemon was killed by signal ${signal}`,
|
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 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempts >= 30) {
|
||||||
|
finish({
|
||||||
|
success: false,
|
||||||
|
port,
|
||||||
|
error: `Proxy daemon did not start within 30 seconds on port ${port}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timeout = setTimeout(poll, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout = setTimeout(poll, 1000);
|
||||||
|
proc.on('error', (error) => {
|
||||||
|
finish({ success: false, port, error: error.message });
|
||||||
|
});
|
||||||
|
proc.on('exit', (code, signal) => {
|
||||||
|
if (code === 0) {
|
||||||
|
finish({
|
||||||
|
success: false,
|
||||||
|
port,
|
||||||
|
error: 'Proxy daemon exited before becoming healthy',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code !== null) {
|
||||||
|
finish({
|
||||||
|
success: false,
|
||||||
|
port,
|
||||||
|
error: `Proxy daemon exited with code ${code}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish({
|
||||||
|
success: false,
|
||||||
|
port,
|
||||||
|
error: `Proxy daemon was killed by signal ${signal}`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
if (requiresExactPort) {
|
||||||
|
return startOnPort(preferredPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 startOnPort(port);
|
||||||
|
if (result.success) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastResult = result;
|
||||||
|
attemptedPorts.add(port);
|
||||||
|
if (!(await isPortOccupied(port))) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
lastResult ?? {
|
||||||
|
success: false,
|
||||||
|
port: preferredPort,
|
||||||
|
error: 'Failed to start proxy',
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user