Merge pull request #683 from kaitranntt/kai/fix/copilot-daemon-liveness-dx

fix(copilot): improve daemon liveness and flag aliases
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-04 06:57:04 -05:00
committed by GitHub
9 changed files with 617 additions and 61 deletions
+13 -20
View File
@@ -30,6 +30,7 @@ import { getGlobalEnvConfig } from './config/unified-config-loader';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
import { getImageAnalysisHookEnv } from './utils/hooks';
import { fail, info, warn } from './utils/ui';
import { isCopilotSubcommandToken } from './copilot/constants';
// Import centralized error handling
import { handleError, runCleanup } from './errors';
@@ -572,25 +573,16 @@ async function main(): Promise<void> {
}
// Special case: copilot command (GitHub Copilot integration)
// Only route to command handler for known subcommands, otherwise treat as profile
const COPILOT_SUBCOMMANDS = [
'auth',
'status',
'models',
'usage',
'start',
'stop',
'enable',
'disable',
'help',
'--help',
'-h',
];
if (firstArg === 'copilot' && args.length > 1 && COPILOT_SUBCOMMANDS.includes(args[1])) {
// `ccs copilot <subcommand>` - route to copilot command handler
const { handleCopilotCommand } = await import('./commands/copilot-command');
const exitCode = await handleCopilotCommand(args.slice(1));
process.exit(exitCode);
// Route known subcommands to command handler, keep all other args as profile passthrough.
if (firstArg === 'copilot' && args.length > 1) {
const copilotToken = args[1];
const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken);
if (shouldRouteToCopilotCommand) {
const { handleCopilotCommand } = await import('./commands/copilot-command');
const exitCode = await handleCopilotCommand(args.slice(1));
process.exit(exitCode);
}
}
// First-time install: offer setup wizard for interactive users
@@ -967,7 +959,8 @@ async function main(): Promise<void> {
const exitCode = await executeCopilotProfile(
copilotConfig,
remainingArgs,
continuityInheritance.claudeConfigDir
continuityInheritance.claudeConfigDir,
claudeCli
);
process.exit(exitCode);
} else if (profileInfo.type === 'settings') {
+11 -3
View File
@@ -16,12 +16,13 @@ import {
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
import { ok, fail, info, color } from '../utils/ui';
import { normalizeCopilotSubcommand } from '../copilot/constants';
/**
* Handle copilot subcommand.
*/
export async function handleCopilotCommand(args: string[]): Promise<number> {
const subcommand = args[0];
const subcommand = normalizeCopilotSubcommand(args[0]);
switch (subcommand) {
case 'auth':
@@ -48,7 +49,8 @@ export async function handleCopilotCommand(args: string[]): Promise<number> {
default:
console.error(fail(`Unknown subcommand: ${subcommand}`));
console.error('');
return handleHelp();
handleHelp();
return 1;
}
}
@@ -77,6 +79,11 @@ function handleHelp(): number {
console.log(' 3. ccs copilot start # Start daemon');
console.log(' 4. ccs copilot usage # Check quota usage');
console.log('');
console.log('Flag aliases:');
console.log(
' ccs copilot --auth | --status | --models | --usage | --start | --stop | --enable | --disable'
);
console.log('');
console.log('Or use the web UI: ccs config → Copilot tab');
console.log('');
return 0;
@@ -101,7 +108,8 @@ async function handleAuth(): Promise<number> {
console.log('');
console.log('Next steps:');
console.log(' 1. Enable copilot: ccs copilot enable');
console.log(' 2. Start daemon: npx copilot-api start');
console.log(' 2. Start daemon: ccs copilot start');
console.log(' (fallback: npx copilot-api start)');
console.log(' 3. Use copilot: ccs copilot');
return 0;
} else {
+47
View File
@@ -0,0 +1,47 @@
/**
* Shared Copilot command tokens and aliases.
* Keep all copilot subcommand routing in one place to avoid drift.
*/
export const COPILOT_SUBCOMMANDS = [
'auth',
'status',
'models',
'usage',
'start',
'stop',
'enable',
'disable',
] as const;
export type CopilotSubcommand = (typeof COPILOT_SUBCOMMANDS)[number];
export const COPILOT_FLAG_ALIASES: Readonly<Record<`--${CopilotSubcommand}`, CopilotSubcommand>> =
Object.freeze({
'--auth': 'auth',
'--status': 'status',
'--models': 'models',
'--usage': 'usage',
'--start': 'start',
'--stop': 'stop',
'--enable': 'enable',
'--disable': 'disable',
});
export const COPILOT_SUBCOMMAND_TOKENS = Object.freeze([
...COPILOT_SUBCOMMANDS,
...Object.keys(COPILOT_FLAG_ALIASES),
'help',
'--help',
'-h',
]);
export function normalizeCopilotSubcommand(token?: string): string | undefined {
if (!token) return token;
const alias = COPILOT_FLAG_ALIASES[token as keyof typeof COPILOT_FLAG_ALIASES];
return alias || token;
}
export function isCopilotSubcommandToken(token?: string): boolean {
return Boolean(token) && COPILOT_SUBCOMMAND_TOKENS.includes(token as string);
}
+145 -19
View File
@@ -10,27 +10,67 @@ import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import { CopilotDaemonStatus } from './types';
import { CopilotConfig } from '../config/unified-config-types';
import { CopilotConfig, DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import { getCopilotDir, getCopilotApiBinPath } from './copilot-package-manager';
import { verifyProcessOwnership } from '../cursor/daemon-process-ownership';
const PID_FILE = path.join(getCopilotDir(), 'daemon.pid');
const DAEMON_HEALTH_MARKER = 'server running';
const MIN_PORT = 1;
const MAX_PORT = 65535;
function isValidPort(port: number): boolean {
return Number.isInteger(port) && port >= MIN_PORT && port <= MAX_PORT;
}
function getConfiguredCopilotPort(): number {
try {
const config = loadOrCreateUnifiedConfig();
const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port;
return isValidPort(port) ? port : DEFAULT_COPILOT_CONFIG.port;
} catch {
return DEFAULT_COPILOT_CONFIG.port;
}
}
function getPidFilePath(): string {
return path.join(getCopilotDir(), 'daemon.pid');
}
/**
* Check if copilot-api daemon is running on the specified port.
* Uses 127.0.0.1 instead of localhost for more reliable local connections.
*/
export async function isDaemonRunning(port: number): Promise<boolean> {
if (!isValidPort(port)) {
return false;
}
return new Promise((resolve) => {
const req = http.request(
{
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(DAEMON_HEALTH_MARKER));
});
}
);
@@ -65,9 +105,10 @@ export async function getDaemonStatus(port: number): Promise<CopilotDaemonStatus
* Read PID from file.
*/
function getPidFromFile(): number | null {
const pidFile = getPidFilePath();
try {
if (fs.existsSync(PID_FILE)) {
const content = fs.readFileSync(PID_FILE, 'utf8').trim();
if (fs.existsSync(pidFile)) {
const content = fs.readFileSync(pidFile, 'utf8').trim();
const pid = parseInt(content, 10);
return isNaN(pid) ? null : pid;
}
@@ -81,12 +122,13 @@ function getPidFromFile(): number | null {
* Write PID to file.
*/
function writePidToFile(pid: number): void {
const pidFile = getPidFilePath();
try {
const dir = path.dirname(PID_FILE);
const dir = path.dirname(pidFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(PID_FILE, pid.toString(), { mode: 0o600 });
fs.writeFileSync(pidFile, pid.toString(), { mode: 0o600 });
} catch {
// Ignore errors
}
@@ -96,9 +138,10 @@ function writePidToFile(pid: number): void {
* Remove PID file.
*/
function removePidFile(): void {
const pidFile = getPidFilePath();
try {
if (fs.existsSync(PID_FILE)) {
fs.unlinkSync(PID_FILE);
if (fs.existsSync(pidFile)) {
fs.unlinkSync(pidFile);
}
} catch {
// Ignore errors
@@ -114,6 +157,13 @@ function removePidFile(): void {
export async function startDaemon(
config: CopilotConfig
): Promise<{ success: boolean; pid?: number; error?: string }> {
if (!isValidPort(config.port)) {
return {
success: false,
error: `Invalid Copilot daemon port ${config.port}. Expected integer between ${MIN_PORT} and ${MAX_PORT}.`,
};
}
// Check if already running
if (await isDaemonRunning(config.port)) {
return { success: true, pid: getPidFromFile() ?? undefined };
@@ -137,6 +187,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 +219,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}`,
});
@@ -191,6 +278,7 @@ export async function startDaemon(
*/
export async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
const pid = getPidFromFile();
const configuredPort = getConfiguredCopilotPort();
if (!pid) {
// No PID file, try to find by port
@@ -199,6 +287,44 @@ export async function stopDaemon(): Promise<{ success: boolean; error?: string }
}
try {
const ownership = verifyProcessOwnership(pid, (commandLine) => {
const lower = commandLine.toLowerCase();
const hasCopilotApiBinary = /copilot-api(\.cmd|\.exe)?/.test(lower);
const hasStartCommand = /\bstart\b/.test(lower);
const hasPortArgument = /--port(?:\s+|=)\d+\b/.test(lower);
// copilot-api is launched as `... copilot-api start --port <n>`
return hasCopilotApiBinary && hasStartCommand && hasPortArgument;
});
if (ownership === 'not-running') {
removePidFile();
return { success: true };
}
if (ownership === 'not-owned') {
// PID was reused by an unrelated process.
// If daemon is still live on configured port, report failure (stop not completed).
if (await isDaemonRunning(configuredPort)) {
return {
success: false,
error: `Refusing to clear PID ${pid}: unrelated process owns PID and daemon is still responding on port ${configuredPort}`,
};
}
removePidFile();
return { success: true };
}
if (ownership === 'unknown') {
// If daemon is not reachable, allow stale PID cleanup.
if (!(await isDaemonRunning(configuredPort))) {
removePidFile();
return { success: true };
}
return {
success: false,
error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`,
};
}
// Send SIGTERM to the process
process.kill(pid, 'SIGTERM');
+7 -4
View File
@@ -76,7 +76,8 @@ export function generateCopilotEnv(
export async function executeCopilotProfile(
config: CopilotConfig,
claudeArgs: string[],
claudeConfigDir?: string
claudeConfigDir?: string,
claudeCliPath: string = 'claude'
): Promise<number> {
// Ensure copilot-api is installed (auto-install if missing, auto-update if outdated)
try {
@@ -95,7 +96,7 @@ export async function executeCopilotProfile(
if (!isCopilotApiInstalled()) {
console.error(fail('copilot-api is not installed.'));
console.error('');
console.error('Install with: ccs copilot --install');
console.error('Install/repair by running: ccs copilot start');
return 1;
}
@@ -125,7 +126,9 @@ export async function executeCopilotProfile(
} else {
console.error(fail('copilot-api daemon is not running.'));
console.error('');
console.error('Start the daemon manually:');
console.error('Start the daemon:');
console.error(' ccs copilot start');
console.error('Fallback manual command:');
console.error(` npx copilot-api start --port ${config.port}`);
console.error('');
console.error('Or enable auto_start in config:');
@@ -158,7 +161,7 @@ export async function executeCopilotProfile(
// Spawn Claude CLI
return new Promise((resolve) => {
const proc = spawn('claude', claudeArgs, {
const proc = spawn(claudeCliPath, claudeArgs, {
stdio: 'inherit',
env,
shell: process.platform === 'win32',
+12 -4
View File
@@ -52,7 +52,10 @@ function getProcessCommandLine(pid: number): string | null {
return null;
}
export function verifyDaemonOwnership(pid: number): DaemonOwnershipStatus {
export function verifyProcessOwnership(
pid: number,
ownershipMatcher: (commandLine: string) => boolean
): DaemonOwnershipStatus {
try {
process.kill(pid, 0);
} catch (err) {
@@ -68,8 +71,13 @@ export function verifyDaemonOwnership(pid: number): DaemonOwnershipStatus {
return 'unknown';
}
const looksLikeCursorDaemon =
commandLine.includes('--ccs-daemon') && commandLine.includes('cursor-daemon-entry');
return ownershipMatcher(commandLine) ? 'owned' : 'not-owned';
}
return looksLikeCursorDaemon ? 'owned' : 'not-owned';
export function verifyDaemonOwnership(pid: number): DaemonOwnershipStatus {
return verifyProcessOwnership(
pid,
(commandLine) =>
commandLine.includes('--ccs-daemon') && commandLine.includes('cursor-daemon-entry')
);
}
+144 -11
View File
@@ -27,6 +27,18 @@ const router = Router();
// Mount settings sub-routes
router.use('/settings', copilotSettingsRoutes);
function parseRequiredModel(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function parseOptionalModel(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* GET /api/copilot/status - Get Copilot status (auth + daemon + install info)
*/
@@ -75,31 +87,152 @@ router.get('/config', (_req: Request, res: Response): void => {
router.put('/config', (req: Request, res: Response): void => {
try {
const updates = req.body;
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
res.status(400).json({ error: 'Request body must be a JSON object' });
return;
}
const payload = updates as Record<string, unknown>;
const allowedKeys = new Set([
'enabled',
'auto_start',
'port',
'account_type',
'rate_limit',
'wait_on_limit',
'model',
'opus_model',
'sonnet_model',
'haiku_model',
]);
const unknownKeys = Object.keys(payload).filter((key) => !allowedKeys.has(key));
if (unknownKeys.length > 0) {
res.status(400).json({
error: `Unknown copilot config field(s): ${unknownKeys.join(', ')}`,
});
return;
}
if ('port' in payload) {
if (typeof payload.port !== 'number' || !Number.isInteger(payload.port)) {
res.status(400).json({ error: 'port must be an integer' });
return;
}
if (payload.port < 1 || payload.port > 65535) {
res.status(400).json({ error: 'port must be between 1 and 65535' });
return;
}
}
if ('enabled' in payload && typeof payload.enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
if ('auto_start' in payload && typeof payload.auto_start !== 'boolean') {
res.status(400).json({ error: 'auto_start must be a boolean' });
return;
}
if ('wait_on_limit' in payload && typeof payload.wait_on_limit !== 'boolean') {
res.status(400).json({ error: 'wait_on_limit must be a boolean' });
return;
}
if (
'account_type' in payload &&
payload.account_type !== 'individual' &&
payload.account_type !== 'business' &&
payload.account_type !== 'enterprise'
) {
res.status(400).json({ error: 'account_type must be individual, business, or enterprise' });
return;
}
if ('rate_limit' in payload) {
if (payload.rate_limit !== null) {
if (typeof payload.rate_limit !== 'number' || !Number.isInteger(payload.rate_limit)) {
res.status(400).json({ error: 'rate_limit must be an integer or null' });
return;
}
if (payload.rate_limit < 0) {
res.status(400).json({ error: 'rate_limit must be >= 0 or null' });
return;
}
}
}
const normalizedModel = parseRequiredModel(payload.model);
if ('model' in payload && !normalizedModel) {
res.status(400).json({ error: 'model must be a non-empty string' });
return;
}
if (
'opus_model' in payload &&
payload.opus_model !== undefined &&
payload.opus_model !== null &&
typeof payload.opus_model !== 'string'
) {
res.status(400).json({ error: 'opus_model must be a string' });
return;
}
if (
'sonnet_model' in payload &&
payload.sonnet_model !== undefined &&
payload.sonnet_model !== null &&
typeof payload.sonnet_model !== 'string'
) {
res.status(400).json({ error: 'sonnet_model must be a string' });
return;
}
if (
'haiku_model' in payload &&
payload.haiku_model !== undefined &&
payload.haiku_model !== null &&
typeof payload.haiku_model !== 'string'
) {
res.status(400).json({ error: 'haiku_model must be a string' });
return;
}
const config = loadOrCreateUnifiedConfig();
// Merge updates with existing config
config.copilot = {
enabled: updates.enabled ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
enabled:
(payload.enabled as boolean) ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
auto_start:
updates.auto_start ?? config.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start,
port: updates.port ?? config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port,
(payload.auto_start as boolean) ??
config.copilot?.auto_start ??
DEFAULT_COPILOT_CONFIG.auto_start,
port: (payload.port as number) ?? config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port,
account_type:
updates.account_type ?? config.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type,
(payload.account_type as 'individual' | 'business' | 'enterprise') ??
config.copilot?.account_type ??
DEFAULT_COPILOT_CONFIG.account_type,
rate_limit:
updates.rate_limit !== undefined
? updates.rate_limit
payload.rate_limit !== undefined
? (payload.rate_limit as number | null)
: (config.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit),
wait_on_limit:
updates.wait_on_limit ??
(payload.wait_on_limit as boolean) ??
config.copilot?.wait_on_limit ??
DEFAULT_COPILOT_CONFIG.wait_on_limit,
model: updates.model ?? config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
model: normalizedModel ?? config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
opus_model:
updates.opus_model !== undefined ? updates.opus_model : config.copilot?.opus_model,
'opus_model' in payload
? parseOptionalModel(payload.opus_model)
: config.copilot?.opus_model,
sonnet_model:
updates.sonnet_model !== undefined ? updates.sonnet_model : config.copilot?.sonnet_model,
'sonnet_model' in payload
? parseOptionalModel(payload.sonnet_model)
: config.copilot?.sonnet_model,
haiku_model:
updates.haiku_model !== undefined ? updates.haiku_model : config.copilot?.haiku_model,
'haiku_model' in payload
? parseOptionalModel(payload.haiku_model)
: config.copilot?.haiku_model,
};
saveUnifiedConfig(config);
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'bun:test';
import {
COPILOT_SUBCOMMANDS,
COPILOT_SUBCOMMAND_TOKENS,
normalizeCopilotSubcommand,
} from '../../../src/copilot/constants';
describe('copilot command aliases', () => {
it('normalizes all supported flag aliases', () => {
for (const subcommand of COPILOT_SUBCOMMANDS) {
expect(normalizeCopilotSubcommand(`--${subcommand}`)).toBe(subcommand);
}
});
it('keeps canonical subcommands unchanged', () => {
for (const subcommand of COPILOT_SUBCOMMANDS) {
expect(normalizeCopilotSubcommand(subcommand)).toBe(subcommand);
}
});
it('returns unknown tokens unchanged', () => {
expect(normalizeCopilotSubcommand('--unknown')).toBe('--unknown');
expect(normalizeCopilotSubcommand('unknown')).toBe('unknown');
});
it('exposes complete routing token list for ccs entrypoint', () => {
for (const subcommand of COPILOT_SUBCOMMANDS) {
expect(COPILOT_SUBCOMMAND_TOKENS).toContain(subcommand);
expect(COPILOT_SUBCOMMAND_TOKENS).toContain(`--${subcommand}`);
}
expect(COPILOT_SUBCOMMAND_TOKENS).toContain('help');
expect(COPILOT_SUBCOMMAND_TOKENS).toContain('--help');
expect(COPILOT_SUBCOMMAND_TOKENS).toContain('-h');
});
});
+203
View File
@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawn } from 'child_process';
import { isDaemonRunning, startDaemon, stopDaemon } from '../../../src/copilot/copilot-daemon';
import { DEFAULT_COPILOT_CONFIG } from '../../../src/config/unified-config-types';
import { getCcsDir } from '../../../src/utils/config-manager';
const activeServers: http.Server[] = [];
let originalCcsHome: string | undefined;
let tempDir: string;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-copilot-daemon-test-'));
process.env.CCS_HOME = tempDir;
});
afterEach(async () => {
await Promise.all(
activeServers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
server.close(() => resolve());
})
)
);
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
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;
}
function writeCopilotPortConfig(port: number): void {
const configPath = path.join(getCcsDir(), 'config.yaml');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `version: 8\ncopilot:\n port: ${port}\n`);
}
describe('copilot daemon health detection', () => {
it('returns false for invalid port inputs', async () => {
expect(await isDaemonRunning(0)).toBe(false);
expect(await isDaemonRunning(65536)).toBe(false);
});
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 returns 200 with empty body', async () => {
const port = await createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('');
});
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);
});
it('fails fast when startDaemon is called with invalid port', async () => {
const result = await startDaemon({
...DEFAULT_COPILOT_CONFIG,
port: 70000,
});
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid Copilot daemon port');
});
});
describe('copilot daemon stop safety', () => {
it('returns failure when stale PID points to unrelated process but daemon is still live', async () => {
const port = await createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Server running');
});
writeCopilotPortConfig(port);
const unrelatedProcess = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], {
detached: true,
stdio: 'ignore',
});
unrelatedProcess.unref();
const unrelatedPid = unrelatedProcess.pid;
expect(unrelatedPid).toBeDefined();
if (!unrelatedPid) {
throw new Error('Failed to spawn unrelated process');
}
const pidFile = path.join(getCcsDir(), 'copilot', 'daemon.pid');
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
fs.writeFileSync(pidFile, String(unrelatedPid));
try {
const result = await stopDaemon();
expect(result.success).toBe(false);
expect(result.error).toContain('daemon is still responding on port');
// Unrelated process should still be alive.
expect(() => process.kill(unrelatedPid, 0)).not.toThrow();
} finally {
try {
process.kill(unrelatedPid, 'SIGTERM');
} catch {
// Process already exited.
}
}
});
it('does not terminate unrelated process from stale PID file', async () => {
writeCopilotPortConfig(65534);
const unrelatedProcess = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], {
detached: true,
stdio: 'ignore',
});
unrelatedProcess.unref();
const unrelatedPid = unrelatedProcess.pid;
expect(unrelatedPid).toBeDefined();
if (!unrelatedPid) {
throw new Error('Failed to spawn unrelated process');
}
const pidFile = path.join(getCcsDir(), 'copilot', 'daemon.pid');
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
fs.writeFileSync(pidFile, String(unrelatedPid));
try {
const result = await stopDaemon();
expect(result.success).toBe(true);
// Unrelated process should still be alive.
expect(() => process.kill(unrelatedPid, 0)).not.toThrow();
} finally {
try {
process.kill(unrelatedPid, 'SIGTERM');
} catch {
// Process already exited.
}
}
});
});