Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/cli/commands/dev/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
listMcpTools,
loadDevEnv,
loadProjectConfig,
onShutdownSignal,
} from '../../operations/dev';
import { OtelCollector, startOtelCollector } from '../../operations/dev/otel';
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
Expand Down Expand Up @@ -449,7 +450,7 @@ export const registerDev = (program: Command) => {
});
server.start().catch(reject);

process.once('SIGINT', () => {
onShutdownSignal(() => {
console.log('\nStopping server...');
collector?.stop();
server.kill();
Expand Down Expand Up @@ -489,6 +490,11 @@ export const registerDev = (program: Command) => {
</LayoutProvider>
);

process.once('SIGTERM', () => {
exitAltScreen();
unmount();
});
Comment thread
avi-alpert marked this conversation as resolved.

return {
success: true as const,
blockingPromise: waitUntilExit().finally(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const mockExistsSync = vi.mocked(existsSync);

vi.mock('../../../../lib/utils/platform', () => ({
getVenvExecutable: (venvPath: string, executable: string) => `${venvPath}/bin/${executable}`,
isWindows: false,
}));

function createMockChildProcess() {
Expand Down
29 changes: 23 additions & 6 deletions src/cli/operations/dev/__tests__/dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockSpawn = vi.fn();
vi.mock('child_process', () => ({
spawn: (...args: unknown[]) => mockSpawn(...args),
execSync: vi.fn(),
}));
vi.mock('../../../../lib/utils/platform', () => ({
isWindows: false,
}));

function createMockChildProcess() {
Expand Down Expand Up @@ -71,11 +75,11 @@ describe('DevServer', () => {
it('calls spawn with correct cmd, args, cwd, env, and stdio when prepare succeeds', async () => {
await server.start();

expect(mockSpawn).toHaveBeenCalledWith('test-cmd', ['--flag'], {
cwd: '/test',
env: { PATH: '/usr/bin' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const spawnOpts = mockSpawn.mock.calls[0]![2] as Record<string, unknown>;
expect(spawnOpts.cwd).toBe('/test');
expect(spawnOpts.env).toEqual({ PATH: '/usr/bin' });
expect(spawnOpts.stdio).toEqual(['ignore', 'pipe', 'pipe']);
expect(spawnOpts.detached).toBe(process.platform !== 'win32');
});

it('returns child process on success', async () => {
Expand Down Expand Up @@ -114,13 +118,26 @@ describe('DevServer', () => {
expect(mockChild.kill).not.toHaveBeenCalled();
});

it('sends SIGTERM first', async () => {
it('sends SIGTERM to child when pid is not available', async () => {
await server.start();

server.kill();
expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM');
});

it('sends SIGTERM to process group when pid is available', async () => {
mockChild.pid = 12345;
const processKillSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);

await server.start();
server.kill();

expect(processKillSpy).toHaveBeenCalledWith(-12345, 'SIGTERM');
expect(mockChild.kill).not.toHaveBeenCalled();

processKillSpy.mockRestore();
});
Comment thread
avi-alpert marked this conversation as resolved.

it('sends SIGKILL after 2s if not killed', async () => {
vi.useFakeTimers();

Expand Down
27 changes: 23 additions & 4 deletions src/cli/operations/dev/dev-server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isWindows } from '../../../lib/utils/platform';
import type { DevConfig } from './config';
import { type ChildProcess, spawn } from 'child_process';
import { type ChildProcess, execSync, spawn } from 'child_process';

export type LogLevel = 'info' | 'warn' | 'error' | 'system';

Expand Down Expand Up @@ -72,22 +73,40 @@ export abstract class DevServer {
cwd: spawnConfig.cwd,
env: spawnConfig.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: !isWindows,
});

this.attachHandlers();
return this.child;
}

/** Kill the dev server process. Sends SIGTERM, then SIGKILL after 2 seconds. */
/** Kill the dev server process tree. Sends SIGTERM to the process group, then SIGKILL after 2 seconds. */
kill(): void {
if (!this.child || this.child.killed) return;
this.child.kill('SIGTERM');
this.signalProcessTree('SIGTERM');
const killTimer = setTimeout(() => {
if (this.child && !this.child.killed) this.child.kill('SIGKILL');
if (this.child && !this.child.killed) this.signalProcessTree('SIGKILL');
}, 2000);
killTimer.unref();
}

private signalProcessTree(signal: NodeJS.Signals): void {
const pid = this.child?.pid;
if (!pid) {
this.child!.kill(signal);
return;
Comment thread
avi-alpert marked this conversation as resolved.
}
try {
if (isWindows) {
execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' });
} else {
process.kill(-pid, signal);
}
} catch {
this.child!.kill(signal);
}
}

/** Mode-specific setup (e.g., venv creation, container image build). Returns false to abort. */
protected abstract prepare(): Promise<boolean>;

Expand Down
2 changes: 1 addition & 1 deletion src/cli/operations/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ export { listMcpTools, callMcpTool, type McpTool, type McpToolsResult } from './

export { invokeAguiStreaming } from './invoke-agui';

export { getEndpointUrl, formatMcpToolList } from './utils';
export { getEndpointUrl, formatMcpToolList, onShutdownSignal } from './utils';

export { loadDevEnv, type DevEnv } from './load-dev-env';
5 changes: 5 additions & 0 deletions src/cli/operations/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ export function convertEntrypointToModule(entrypoint: string): string {
return `${path}:app`;
}

export function onShutdownSignal(handler: () => void): void {
process.once('SIGINT', handler);
process.once('SIGTERM', handler);
}

export function openBrowser(url: string): void {
const isWindows = process.platform === 'win32';
const cmd = isWindows ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
Expand Down
4 changes: 2 additions & 2 deletions src/cli/operations/dev/web-ui/run-web-ui.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ExecLogger } from '../../../logging';
import { findAvailablePort } from '../server';
import { openBrowser } from '../utils';
import { onShutdownSignal, openBrowser } from '../utils';
import { WEB_UI_DEFAULT_PORT } from './constants';
import { type WebUIOptions, WebUIServer } from './web-server';

Expand Down Expand Up @@ -49,7 +49,7 @@ export async function runWebUI(opts: RunWebUIOptions): Promise<void> {

webUI.start();

process.on('SIGINT', () => {
onShutdownSignal(() => {
console.log('\nStopping servers...');
webUI.stop();
});
Expand Down
Loading