mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
fix(copilot): improve daemon liveness and flag aliases
This commit is contained in:
@@ -582,6 +582,14 @@ async function main(): Promise<void> {
|
||||
'stop',
|
||||
'enable',
|
||||
'disable',
|
||||
'--auth',
|
||||
'--status',
|
||||
'--models',
|
||||
'--usage',
|
||||
'--start',
|
||||
'--stop',
|
||||
'--enable',
|
||||
'--disable',
|
||||
'help',
|
||||
'--help',
|
||||
'-h',
|
||||
|
||||
@@ -21,7 +21,18 @@ import { ok, fail, info, color } from '../utils/ui';
|
||||
* Handle copilot subcommand.
|
||||
*/
|
||||
export async function handleCopilotCommand(args: string[]): Promise<number> {
|
||||
const subcommand = args[0];
|
||||
const subcommandAliasMap: Record<string, string> = {
|
||||
'--auth': 'auth',
|
||||
'--status': 'status',
|
||||
'--models': 'models',
|
||||
'--usage': 'usage',
|
||||
'--start': 'start',
|
||||
'--stop': 'stop',
|
||||
'--enable': 'enable',
|
||||
'--disable': 'disable',
|
||||
};
|
||||
const rawSubcommand = args[0];
|
||||
const subcommand = (rawSubcommand && subcommandAliasMap[rawSubcommand]) || rawSubcommand;
|
||||
|
||||
switch (subcommand) {
|
||||
case 'auth':
|
||||
|
||||
@@ -25,12 +25,26 @@ export async function isDaemonRunning(port: number): Promise<boolean> {
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: '/usage',
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
timeout: 3000,
|
||||
},
|
||||
(res) => {
|
||||
resolve(res.statusCode === 200);
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(body.trim().toLowerCase().includes('server running'));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -137,6 +151,20 @@ export async function startDaemon(
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let proc: ChildProcess;
|
||||
let resolved = false;
|
||||
let checkTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const safeResolve = (result: { success: boolean; pid?: number; error?: string }) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (checkTimeout) {
|
||||
clearTimeout(checkTimeout);
|
||||
}
|
||||
if (!result.success) {
|
||||
removePidFile();
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
try {
|
||||
proc = spawn(binPath, args, {
|
||||
@@ -155,30 +183,53 @@ export async function startDaemon(
|
||||
// Wait for daemon to be ready (poll for up to 30 seconds)
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30;
|
||||
const checkInterval = setInterval(async () => {
|
||||
const pollHealth = async () => {
|
||||
if (resolved) return;
|
||||
attempts++;
|
||||
|
||||
if (await isDaemonRunning(config.port)) {
|
||||
clearInterval(checkInterval);
|
||||
resolve({ success: true, pid: proc.pid });
|
||||
safeResolve({ success: true, pid: proc.pid });
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(checkInterval);
|
||||
resolve({
|
||||
if (proc.pid) {
|
||||
try {
|
||||
process.kill(proc.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Already exited
|
||||
}
|
||||
}
|
||||
safeResolve({
|
||||
success: false,
|
||||
error: 'Daemon did not start within 30 seconds',
|
||||
});
|
||||
} else {
|
||||
checkTimeout = setTimeout(pollHealth, 1000);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
checkTimeout = setTimeout(pollHealth, 1000);
|
||||
|
||||
proc.on('error', (err) => {
|
||||
clearInterval(checkInterval);
|
||||
resolve({
|
||||
safeResolve({
|
||||
success: false,
|
||||
error: `Failed to start daemon: ${err.message}`,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
if (code === null) {
|
||||
safeResolve({
|
||||
success: false,
|
||||
error: `Daemon process was killed by signal ${signal}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
safeResolve({
|
||||
success: false,
|
||||
error: `Daemon process exited with code ${code}`,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({
|
||||
safeResolve({
|
||||
success: false,
|
||||
error: `Failed to spawn daemon: ${(err as Error).message}`,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import * as http from 'http';
|
||||
import { isDaemonRunning } from '../../../src/copilot/copilot-daemon';
|
||||
|
||||
const activeServers: http.Server[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
activeServers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
async function createServer(
|
||||
handler: (req: http.IncomingMessage, res: http.ServerResponse<http.IncomingMessage>) => void
|
||||
): Promise<number> {
|
||||
const server = http.createServer(handler);
|
||||
activeServers.push(server);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve server port');
|
||||
}
|
||||
|
||||
return address.port;
|
||||
}
|
||||
|
||||
describe('copilot daemon health detection', () => {
|
||||
it('returns false when no daemon is running on port', async () => {
|
||||
const running = await isDaemonRunning(19998);
|
||||
expect(running).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when daemon root endpoint confirms server is running', async () => {
|
||||
const port = await createServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('Server running');
|
||||
});
|
||||
|
||||
const running = await isDaemonRunning(port);
|
||||
expect(running).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when root endpoint returns 200 but unexpected body', async () => {
|
||||
const port = await createServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('ok');
|
||||
});
|
||||
|
||||
const running = await isDaemonRunning(port);
|
||||
expect(running).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when root endpoint is non-200', async () => {
|
||||
const port = await createServer((_req, res) => {
|
||||
res.writeHead(503, { 'Content-Type': 'text/plain' });
|
||||
res.end('unavailable');
|
||||
});
|
||||
|
||||
const running = await isDaemonRunning(port);
|
||||
expect(running).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user