mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-23 18:24:54 +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',
|
'stop',
|
||||||
'enable',
|
'enable',
|
||||||
'disable',
|
'disable',
|
||||||
|
'--auth',
|
||||||
|
'--status',
|
||||||
|
'--models',
|
||||||
|
'--usage',
|
||||||
|
'--start',
|
||||||
|
'--stop',
|
||||||
|
'--enable',
|
||||||
|
'--disable',
|
||||||
'help',
|
'help',
|
||||||
'--help',
|
'--help',
|
||||||
'-h',
|
'-h',
|
||||||
|
|||||||
@@ -21,7 +21,18 @@ import { ok, fail, info, color } from '../utils/ui';
|
|||||||
* Handle copilot subcommand.
|
* Handle copilot subcommand.
|
||||||
*/
|
*/
|
||||||
export async function handleCopilotCommand(args: string[]): Promise<number> {
|
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) {
|
switch (subcommand) {
|
||||||
case 'auth':
|
case 'auth':
|
||||||
|
|||||||
@@ -25,12 +25,26 @@ export async function isDaemonRunning(port: number): Promise<boolean> {
|
|||||||
{
|
{
|
||||||
hostname: '127.0.0.1',
|
hostname: '127.0.0.1',
|
||||||
port,
|
port,
|
||||||
path: '/usage',
|
path: '/',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
},
|
},
|
||||||
(res) => {
|
(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) => {
|
return new Promise((resolve) => {
|
||||||
let proc: ChildProcess;
|
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 {
|
try {
|
||||||
proc = spawn(binPath, args, {
|
proc = spawn(binPath, args, {
|
||||||
@@ -155,30 +183,53 @@ export async function startDaemon(
|
|||||||
// Wait for daemon to be ready (poll for up to 30 seconds)
|
// Wait for daemon to be ready (poll for up to 30 seconds)
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 30;
|
const maxAttempts = 30;
|
||||||
const checkInterval = setInterval(async () => {
|
const pollHealth = async () => {
|
||||||
|
if (resolved) return;
|
||||||
attempts++;
|
attempts++;
|
||||||
|
|
||||||
if (await isDaemonRunning(config.port)) {
|
if (await isDaemonRunning(config.port)) {
|
||||||
clearInterval(checkInterval);
|
safeResolve({ success: true, pid: proc.pid });
|
||||||
resolve({ success: true, pid: proc.pid });
|
|
||||||
} else if (attempts >= maxAttempts) {
|
} else if (attempts >= maxAttempts) {
|
||||||
clearInterval(checkInterval);
|
if (proc.pid) {
|
||||||
resolve({
|
try {
|
||||||
|
process.kill(proc.pid, 'SIGTERM');
|
||||||
|
} catch {
|
||||||
|
// Already exited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
safeResolve({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Daemon did not start within 30 seconds',
|
error: 'Daemon did not start within 30 seconds',
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
checkTimeout = setTimeout(pollHealth, 1000);
|
||||||
}
|
}
|
||||||
}, 1000);
|
};
|
||||||
|
checkTimeout = setTimeout(pollHealth, 1000);
|
||||||
|
|
||||||
proc.on('error', (err) => {
|
proc.on('error', (err) => {
|
||||||
clearInterval(checkInterval);
|
safeResolve({
|
||||||
resolve({
|
|
||||||
success: false,
|
success: false,
|
||||||
error: `Failed to start daemon: ${err.message}`,
|
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) {
|
} catch (err) {
|
||||||
resolve({
|
safeResolve({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Failed to spawn daemon: ${(err as Error).message}`,
|
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