From 4b1cda25d945e6482be68804907177a8ad38489e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 4 Mar 2026 16:13:44 +0700 Subject: [PATCH] fix(copilot): improve daemon liveness and flag aliases --- src/ccs.ts | 8 +++ src/commands/copilot-command.ts | 13 +++- src/copilot/copilot-daemon.ts | 73 +++++++++++++++++++---- tests/unit/copilot/copilot-daemon.test.ts | 71 ++++++++++++++++++++++ 4 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 tests/unit/copilot/copilot-daemon.test.ts diff --git a/src/ccs.ts b/src/ccs.ts index 49e5c898..8790e839 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -582,6 +582,14 @@ async function main(): Promise { 'stop', 'enable', 'disable', + '--auth', + '--status', + '--models', + '--usage', + '--start', + '--stop', + '--enable', + '--disable', 'help', '--help', '-h', diff --git a/src/commands/copilot-command.ts b/src/commands/copilot-command.ts index 7ce38232..7184a120 100644 --- a/src/commands/copilot-command.ts +++ b/src/commands/copilot-command.ts @@ -21,7 +21,18 @@ import { ok, fail, info, color } from '../utils/ui'; * Handle copilot subcommand. */ export async function handleCopilotCommand(args: string[]): Promise { - const subcommand = args[0]; + const subcommandAliasMap: Record = { + '--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': diff --git a/src/copilot/copilot-daemon.ts b/src/copilot/copilot-daemon.ts index 36d492f7..dc6cbbef 100644 --- a/src/copilot/copilot-daemon.ts +++ b/src/copilot/copilot-daemon.ts @@ -25,12 +25,26 @@ export async function isDaemonRunning(port: number): Promise { { 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}`, }); diff --git a/tests/unit/copilot/copilot-daemon.test.ts b/tests/unit/copilot/copilot-daemon.test.ts new file mode 100644 index 00000000..9de0c02e --- /dev/null +++ b/tests/unit/copilot/copilot-daemon.test.ts @@ -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((resolve) => { + server.close(() => resolve()); + }) + ) + ); +}); + +async function createServer( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void +): Promise { + const server = http.createServer(handler); + activeServers.push(server); + + await new Promise((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); + }); +});