fix(copilot): improve daemon liveness and flag aliases

This commit is contained in:
Tam Nhu Tran
2026-03-04 16:14:12 +07:00
parent 571ba4946c
commit 4b1cda25d9
4 changed files with 153 additions and 12 deletions
+8
View File
@@ -582,6 +582,14 @@ async function main(): Promise<void> {
'stop',
'enable',
'disable',
'--auth',
'--status',
'--models',
'--usage',
'--start',
'--stop',
'--enable',
'--disable',
'help',
'--help',
'-h',
+12 -1
View File
@@ -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':
+62 -11
View File
@@ -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}`,
});
+71
View File
@@ -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);
});
});