From 2cb003116001fd0aa5ee91766907805c2ce8cbcb Mon Sep 17 00:00:00 2001 From: Gaurav <65115466+gauravyad86@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:58:11 +0530 Subject: [PATCH] fix(cli): add bootstrap fast paths (#6188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): add bootstrap fast paths * fix(cli): address bootstrap review feedback * test(core): make MCP retry backoff test deterministic * fix(cli): address bootstrap validation feedback * fix(cli): keep global-flag MCP invocations on full parser * fix(cli): harden bootstrap review gaps * fix(cli): copy package wrapper from script directory * fix(cli): cover bootstrap review edge cases * test(cli): cover bootstrap fallback paths * fix(cli): minimize wrapper version imports --------- Co-authored-by: 易良 <1204183885@qq.com> --- esbuild.config.js | 2 +- packages/cli/index.ts | 141 +---- packages/cli/src/cli.test.ts | 602 ++++++++++++++++++++++ packages/cli/src/cli.ts | 469 +++++++++++++++++ packages/cli/src/serve/fast-path.test.ts | 11 +- packages/core/src/tools/mcp-retry.test.ts | 31 +- scripts/cli-entry.js | 83 ++- scripts/prepare-package.js | 35 +- 8 files changed, 1168 insertions(+), 206 deletions(-) create mode 100644 packages/cli/src/cli.test.ts create mode 100644 packages/cli/src/cli.ts diff --git a/esbuild.config.js b/esbuild.config.js index 86546acb7c..99c3674919 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -82,7 +82,7 @@ const external = [ const BUNDLE_CHUNK_DIR = 'chunks'; const mainBuild = esbuild.build({ - entryPoints: { cli: 'packages/cli/index.ts' }, + entryPoints: { cli: 'packages/cli/src/cli.ts' }, bundle: true, outdir: 'dist', entryNames: '[name]', diff --git a/packages/cli/index.ts b/packages/cli/index.ts index a3e9b41c66..88bab41c5d 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -6,145 +6,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { initStartupProfiler } from './src/utils/startupProfiler.js'; -import { isServeFastPathArgv } from './src/serve/fast-path-argv.js'; - -// Must run before any other imports to capture the earliest possible T0. -initStartupProfiler(); - -import { initCpuProfiler } from './src/utils/cpuProfiler.js'; -// Initialize early to register SIGUSR1 handler and start recording when -// QWEN_CODE_CPU_PROFILE=1, capturing as much of the startup as possible. -initCpuProfiler(); +import { runCliEntryPoint } from './src/cli.js'; // --- Global Entry Point --- -function writeStderrLine(line: string): void { - process.stderr.write(line.endsWith('\n') ? line : `${line}\n`); -} - -// Suppress known race conditions in @lydell/node-pty. -// -// PTY errors that are expected due to timing races between process exit -// and I/O operations. These should not crash the app. -// -// References: -// - https://github.com/microsoft/node-pty/issues/178 (EIO on macOS/Linux) -// - https://github.com/microsoft/node-pty/issues/827 (resize on Windows) -const getErrnoCode = (error: unknown): string | undefined => { - if (!error || typeof error !== 'object') { - return undefined; - } - const code = (error as { code?: unknown }).code; - return typeof code === 'string' ? code : undefined; -}; - -const isExpectedPtyRaceError = (error: unknown): boolean => { - if (!(error instanceof Error)) { - return false; - } - - const message = error.message; - const code = getErrnoCode(error); - - // EIO: PTY read race on macOS/Linux - code + PTY context required - // https://github.com/microsoft/node-pty/issues/178 - if ( - (code === 'EIO' && message.includes('read')) || - message.includes('read EIO') - ) { - return true; - } - - // EAGAIN: transient non-blocking read error from PTY fd - if ( - (code === 'EAGAIN' && message.includes('read')) || - message.includes('read EAGAIN') - ) { - return true; - } - - // PTY-specific resize/exit race errors - require PTY context in message - if ( - message.includes('ioctl(2) failed, EBADF') || - message.includes('Cannot resize a pty that has already exited') - ) { - return true; - } - - return false; -}; - -async function runCliEntry(): Promise { - if (isServeFastPathArgv(process.argv.slice(2))) { - const { tryRunServeFastPath } = await import('./src/serve/fast-path.js'); - if (await tryRunServeFastPath()) return; - } - - const { main } = await import('./src/gemini.js'); - await main(); -} - -async function handleCriticalError(error: unknown): Promise { - const [{ FatalError }, { AlreadyReportedError }] = await Promise.all([ - import('@qwen-code/qwen-code-core'), - import('./src/utils/errors.js'), - ]); - - if (error instanceof FatalError) { - let errorMessage = error.message; - if (!process.env['NO_COLOR']) { - errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; - } - console.error(errorMessage); - process.exit(error.exitCode); - } - // AlreadyReportedError means an upstream layer (e.g. the non-interactive - // stream-error handler) has already written the user-facing message to - // stderr and just wants to surface a non-zero exit code. Don't print - // "An unexpected critical error occurred:" with a stack trace — that - // framing is for genuinely unexpected, programmer-level bugs, and a - // routine 4xx from an upstream API does not qualify. - if (error instanceof AlreadyReportedError) { - process.exit(error.exitCode); - } - console.error('An unexpected critical error occurred:'); - if (error instanceof Error) { - console.error(error.stack); - } else { - console.error(String(error)); - } - process.exit(1); -} - -process.on('uncaughtException', (error) => { - if (isExpectedPtyRaceError(error)) { - return; - } - - if (error instanceof Error) { - writeStderrLine(error.stack ?? error.message); - } else { - writeStderrLine(String(error)); - } - process.exit(1); -}); - -runCliEntry().catch((error: unknown) => { - void handleCriticalError(error).catch((handlerError: unknown) => { - console.error('An unexpected critical error occurred:'); - console.error('Original error:'); - if (error instanceof Error) { - console.error(error.stack); - } else { - console.error(String(error)); - } - console.error('Error handler failed:'); - if (handlerError instanceof Error) { - console.error(handlerError.stack); - } else { - console.error(String(handlerError)); - } - process.exit(1); - }); -}); +void runCliEntryPoint(); diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts new file mode 100644 index 0000000000..85eef6a000 --- /dev/null +++ b/packages/cli/src/cli.test.ts @@ -0,0 +1,602 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Argv } from 'yargs'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { + copyFileSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { FatalError } from '@qwen-code/qwen-code-core'; +import { AlreadyReportedError } from './utils/errors.js'; +import { + MCP_COMMANDS, + TOP_LEVEL_COMMANDS, + handleCriticalError, + isExpectedPtyRaceError, + resolveBootstrapRoute, + runCliEntry, + runCliEntryPoint, +} from './cli.js'; + +const mocks = vi.hoisted(() => ({ + main: vi.fn(), + tryRunServeFastPath: vi.fn(), + initStartupProfiler: vi.fn(), + initCpuProfiler: vi.fn(), + mcpHandler: vi.fn(), + mcpBuilder: vi.fn(), + mcpListHandler: vi.fn(), + mcpAddHandler: vi.fn(), + getCliVersion: vi.fn(), +})); + +vi.mock('./gemini.js', () => ({ + main: mocks.main, +})); + +vi.mock('./serve/fast-path.js', () => ({ + tryRunServeFastPath: mocks.tryRunServeFastPath, +})); + +vi.mock('./utils/startupProfiler.js', () => ({ + initStartupProfiler: mocks.initStartupProfiler, +})); + +vi.mock('./utils/cpuProfiler.js', () => ({ + initCpuProfiler: mocks.initCpuProfiler, +})); + +vi.mock('./utils/version.js', () => ({ + getCliVersion: mocks.getCliVersion, +})); + +vi.mock('./commands/mcp.js', () => ({ + mcpCommand: { + command: 'mcp', + describe: 'Manage MCP servers', + builder: (yargs: Argv) => { + mocks.mcpBuilder(); + return yargs + .command({ + command: 'list', + describe: 'List all configured MCP servers', + handler: mocks.mcpListHandler, + }) + .command({ + command: 'add ', + describe: 'Add a server', + handler: mocks.mcpAddHandler, + }) + .demandCommand(1, 'You need at least one command before continuing.'); + }, + handler: mocks.mcpHandler, + }, +})); + +describe('resolveBootstrapRoute', () => { + it('routes top-level help, version, serve, and mcp correctly', async () => { + expect(resolveBootstrapRoute(['--help'])).toBe('help'); + expect(resolveBootstrapRoute(['--version'])).toBe('version'); + expect(resolveBootstrapRoute(['mcp', '--version'])).toBe('version'); + expect(resolveBootstrapRoute(['serve', '--help'])).toBe('serve'); + expect(resolveBootstrapRoute(['mcp', '--help'])).toBe('mcp'); + }); + + it('keeps bundled entrypoint paths out of the route detection', async () => { + expect(resolveBootstrapRoute(['/repo/dist/cli.js', '--help'])).toBe('help'); + expect( + resolveBootstrapRoute(['C:\\repo\\dist\\cli.js', 'mcp', '--help']), + ).toBe('mcp'); + }); + + it('falls back to the default route for normal interactive startup', async () => { + expect(resolveBootstrapRoute([])).toBe('default'); + expect(resolveBootstrapRoute(['--model', 'gpt-4', 'Hello'])).toBe( + 'default', + ); + expect(resolveBootstrapRoute(['--safe-mode', 'mcp', 'list'])).toBe( + 'default', + ); + }); + + it('does not treat values for global flags as positional commands or bootstrap flags', () => { + expect(resolveBootstrapRoute(['--model', 'gpt-4', '--help'])).toBe('help'); + expect(resolveBootstrapRoute(['-p', 'hello', '--help'])).toBe('help'); + expect(resolveBootstrapRoute(['--model', '-v'])).toBe('default'); + }); + + it('does not treat flags after -- as bootstrap flags', () => { + expect(resolveBootstrapRoute(['--', '--version'])).toBe('default'); + expect(resolveBootstrapRoute(['mcp', '--', '--version'])).toBe('mcp'); + }); +}); + +describe('runCliEntry', () => { + const savedEnv = { + CLI_VERSION: process.env['CLI_VERSION'], + }; + + let stdout: string[]; + let stderr: string[]; + let savedExitCode: string | number | null | undefined; + + beforeEach(() => { + stdout = []; + stderr = []; + savedExitCode = process.exitCode; + process.exitCode = undefined; + vi.clearAllMocks(); + mocks.tryRunServeFastPath.mockResolvedValue(false); + mocks.getCliVersion.mockResolvedValue('fallback-version'); + process.env['CLI_VERSION'] = '9.9.9'; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + }); + + afterEach(() => { + process.exitCode = savedExitCode; + if (savedEnv.CLI_VERSION === undefined) { + delete process.env['CLI_VERSION']; + } else { + process.env['CLI_VERSION'] = savedEnv.CLI_VERSION; + } + vi.restoreAllMocks(); + }); + + it('prints the version without loading the full CLI graph', async () => { + await runCliEntry(['--version']); + + expect(stdout.join('')).toContain('9.9.9'); + expect(mocks.main).not.toHaveBeenCalled(); + expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled(); + expect(mocks.initStartupProfiler).not.toHaveBeenCalled(); + expect(mocks.initCpuProfiler).not.toHaveBeenCalled(); + }); + + it('falls back to getCliVersion when CLI_VERSION is unset', async () => { + delete process.env['CLI_VERSION']; + + await runCliEntry(['--version']); + + expect(stdout.join('')).toContain('fallback-version'); + expect(mocks.getCliVersion).toHaveBeenCalledTimes(1); + expect(mocks.main).not.toHaveBeenCalled(); + expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled(); + }); + + it('prints top-level help without loading the full CLI graph', async () => { + await runCliEntry(['--help']); + + const helpText = stdout.join(''); + expect(helpText).toContain('Usage: qwen [options] [command]'); + expect(helpText).toContain('Manage Qwen Code hooks'); + expect(helpText).toContain('Manage MCP servers'); + expect(helpText).toContain('Run Qwen Code as a local HTTP daemon'); + expect(helpText).toContain('--model'); + expect(helpText).toContain('-p, --prompt'); + expect(helpText).toContain('--safe-mode'); + expect(helpText).toContain('-s, --sandbox'); + expect(helpText).toContain('-o, --output-format'); + expect(helpText).toContain('-r, --resume'); + expect(mocks.main).not.toHaveBeenCalled(); + expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled(); + expect(mocks.initStartupProfiler).not.toHaveBeenCalled(); + expect(mocks.initCpuProfiler).not.toHaveBeenCalled(); + }); + + it('routes the MCP help path without booting gemini', async () => { + await runCliEntry(['mcp', '--help']); + + expect(stdout.join('')).toContain('Manage MCP servers'); + expect(mocks.main).not.toHaveBeenCalled(); + expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled(); + expect(mocks.initStartupProfiler).not.toHaveBeenCalled(); + expect(mocks.initCpuProfiler).not.toHaveBeenCalled(); + expect(mocks.mcpBuilder).not.toHaveBeenCalled(); + }); + + it('does not execute MCP subcommands when showing subcommand help', async () => { + await runCliEntry(['mcp', 'list', '--help']); + + const helpText = stdout.join(''); + expect(helpText).toContain('List all configured MCP servers'); + expect(mocks.mcpListHandler).not.toHaveBeenCalled(); + expect(mocks.main).not.toHaveBeenCalled(); + expect(mocks.initStartupProfiler).not.toHaveBeenCalled(); + expect(mocks.initCpuProfiler).not.toHaveBeenCalled(); + }); + + it('executes MCP subcommands through the fast path', async () => { + await runCliEntry(['mcp', 'list']); + + expect(mocks.mcpListHandler).toHaveBeenCalledTimes(1); + expect(mocks.main).not.toHaveBeenCalled(); + expect(mocks.initStartupProfiler).not.toHaveBeenCalled(); + expect(mocks.initCpuProfiler).not.toHaveBeenCalled(); + }); + + it('executes MCP subcommands after -- through the fast path', async () => { + await runCliEntry(['mcp', '--', 'list']); + + expect(mocks.mcpListHandler).toHaveBeenCalledTimes(1); + expect(mocks.main).not.toHaveBeenCalled(); + expect(mocks.initStartupProfiler).not.toHaveBeenCalled(); + expect(mocks.initCpuProfiler).not.toHaveBeenCalled(); + }); + + it('uses the full CLI when global flags precede MCP commands', async () => { + await runCliEntry(['--safe-mode', 'mcp', 'list']); + + expect(mocks.main).toHaveBeenCalledTimes(1); + expect(mocks.mcpListHandler).not.toHaveBeenCalled(); + }); + + it('fails MCP fast-path validation without loading the full CLI', async () => { + await runCliEntry(['mcp', 'doesnotexist']); + + expect(process.exitCode).toBe(1); + expect(stderr.join('')).toContain('Unknown command: doesnotexist'); + expect(mocks.mcpListHandler).not.toHaveBeenCalled(); + expect(mocks.main).not.toHaveBeenCalled(); + expect(mocks.initStartupProfiler).not.toHaveBeenCalled(); + expect(mocks.initCpuProfiler).not.toHaveBeenCalled(); + }); + + it('does not run MCP subcommands with unknown options', async () => { + await runCliEntry(['mcp', 'list', '--unknown']); + + expect(process.exitCode).toBe(1); + expect(stderr.join('')).toContain('Unknown argument: unknown'); + expect(mocks.mcpListHandler).not.toHaveBeenCalled(); + expect(mocks.main).not.toHaveBeenCalled(); + }); + + it('reports routine MCP argument errors without loading the full CLI', async () => { + await runCliEntry(['mcp', 'add']); + + expect(process.exitCode).toBe(1); + expect(stderr.join('')).toContain('Not enough non-option arguments'); + expect(mocks.mcpAddHandler).not.toHaveBeenCalled(); + expect(mocks.main).not.toHaveBeenCalled(); + }); + + it('keeps the serve fast path ahead of the full CLI startup', async () => { + mocks.tryRunServeFastPath.mockResolvedValue(true); + + await runCliEntry(['serve']); + + expect(mocks.tryRunServeFastPath).toHaveBeenCalledWith(['serve']); + expect(mocks.main).not.toHaveBeenCalled(); + }); + + it('initializes profilers once when the serve fast path falls back', async () => { + mocks.tryRunServeFastPath.mockResolvedValue(false); + + await runCliEntry(['serve']); + + expect(mocks.tryRunServeFastPath).toHaveBeenCalledWith(['serve']); + expect(mocks.main).toHaveBeenCalledTimes(1); + }); + + it('loads gemini on the default path', async () => { + await runCliEntry([]); + + expect(mocks.main).toHaveBeenCalledTimes(1); + }); +}); + +describe('bootstrap import boundaries', () => { + it('keeps fast-path-only dependencies out of static imports', () => { + const source = readFileSync('src/cli.ts', 'utf8'); + + expect(source).not.toContain("import yargs from 'yargs'"); + expect(source).not.toContain("from '@qwen-code/qwen-code-core'"); + expect(source).not.toContain("import './gemini.js'"); + expect(source).not.toContain("import { main } from './gemini.js'"); + }); + + it('initializes profilers during bootstrap module evaluation', () => { + const source = readFileSync('src/cli.ts', 'utf8'); + + expect(source).toContain( + "import { initStartupProfiler } from './utils/startupProfiler.js'", + ); + expect(source).toContain( + "import { initCpuProfiler } from './utils/cpuProfiler.js'", + ); + expect(source.indexOf('initStartupProfiler();')).toBeLessThan( + source.indexOf('export async function runCliEntry('), + ); + expect(source.indexOf('initCpuProfiler();')).toBeLessThan( + source.indexOf('export async function runCliEntry('), + ); + }); + + it('uses the bootstrap file as the production bundle entry', () => { + const source = readFileSync('../../esbuild.config.js', 'utf8'); + + expect(source).toContain("entryPoints: { cli: 'packages/cli/src/cli.ts' }"); + }); + + it('keeps bootstrap fast paths in-process in the npm bin wrapper', () => { + const source = readFileSync('../../scripts/cli-entry.js', 'utf8'); + + expect(source).toContain('function isInProcessFastPath()'); + expect(source).toContain("first === 'serve'"); + expect(source).toContain("first === 'mcp'"); + expect(source).toContain("hasFlag('--help', '-h')"); + expect(source).toContain("hasFlag('--version', '-v')"); + }); + + it('prints CLI_VERSION from the npm bin wrapper version shortcut', () => { + const output = execFileSync( + process.execPath, + ['../../scripts/cli-entry.js', '--version'], + { + encoding: 'utf8', + env: { ...process.env, CLI_VERSION: '7.7.7-test' }, + }, + ); + + expect(output).toBe('7.7.7-test\n'); + }); + + it('reads package.json from the npm bin wrapper version shortcut', () => { + const expectedVersion = JSON.parse( + readFileSync('../../package.json', 'utf8'), + ).version; + const env = { ...process.env }; + delete env['CLI_VERSION']; + + const output = execFileSync( + process.execPath, + ['../../scripts/cli-entry.js', '--version'], + { + encoding: 'utf8', + env, + }, + ); + + expect(output).toBe(`${expectedVersion}\n`); + }); + + it('falls through to cli.js when wrapper package.json parsing fails', () => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-entry-')); + try { + copyFileSync( + '../../scripts/cli-entry.js', + path.join(tempDir, 'cli-entry.mjs'), + ); + writeFileSync( + path.join(tempDir, 'cli.js'), + "process.stdout.write('fallback-cli\\n');\n", + ); + const env = { ...process.env }; + delete env['CLI_VERSION']; + + const output = execFileSync( + process.execPath, + [path.join(tempDir, 'cli-entry.mjs'), '--version'], + { + encoding: 'utf8', + env, + }, + ); + + expect(output).toBe('fallback-cli\n'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('copies the npm bin wrapper into the package instead of duplicating it', () => { + const source = readFileSync('../../scripts/prepare-package.js', 'utf8'); + + expect(source).toContain( + "fs.copyFileSync(path.join(__dirname, 'cli-entry.js'), cliEntryPath)", + ); + expect(source).not.toContain('const cliEntryContent = `'); + }); + + it('keeps bootstrap top-level help commands aligned with config registrations', () => { + const configSource = readFileSync('src/config/config.ts', 'utf8'); + const commandNameByIdentifier = new Map([ + ['authCommand', 'auth'], + ['channelCommand', 'channel'], + ['extensionsCommand', 'extensions'], + ['hooksCommand', 'hooks'], + ['mcpCommand', 'mcp'], + ['reviewCommand', 'review'], + ['serveCommand', 'serve'], + ['sessionsCommand', 'sessions'], + ]); + const registeredIdentifiers = [ + ...configSource.matchAll(/\.command\((\w+Command)\)/g), + ].map((match) => match[1]!); + const bootstrapCommands = new Set( + TOP_LEVEL_COMMANDS.map(([command]) => command.split(' ')[0]), + ); + + expect(registeredIdentifiers).toHaveLength(commandNameByIdentifier.size); + for (const identifier of registeredIdentifiers) { + const commandName = commandNameByIdentifier.get(identifier); + expect(commandName, `missing mapping for ${identifier}`).toBeDefined(); + expect(bootstrapCommands).toContain(commandName); + } + }); + + it('keeps bootstrap MCP help commands aligned with MCP registrations', () => { + const mcpSource = readFileSync('src/commands/mcp.ts', 'utf8'); + const commandNameByIdentifier = new Map([ + ['addCommand', 'add'], + ['removeCommand', 'remove'], + ['listCommand', 'list'], + ['reconnectCommand', 'reconnect'], + ['approveCommand', 'approve'], + ['rejectCommand', 'reject'], + ]); + const registeredIdentifiers = [ + ...mcpSource.matchAll(/\.command\((\w+Command)\)/g), + ].map((match) => match[1]!); + const bootstrapCommands = new Set( + MCP_COMMANDS.map(([command]) => command.split(' ')[0]), + ); + + expect(registeredIdentifiers).toHaveLength(commandNameByIdentifier.size); + for (const identifier of registeredIdentifiers) { + const commandName = commandNameByIdentifier.get(identifier); + expect(commandName, `missing mapping for ${identifier}`).toBeDefined(); + expect(bootstrapCommands).toContain(commandName); + } + }); +}); + +describe('bootstrap error handling', () => { + const savedEnv = { + NO_COLOR: process.env['NO_COLOR'], + }; + + let stderr: string[]; + + beforeEach(() => { + stderr = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + vi.spyOn(process, 'exit').mockImplementation(((code) => { + throw new Error(`process.exit:${String(code)}`); + }) as typeof process.exit); + }); + + afterEach(() => { + if (savedEnv.NO_COLOR === undefined) { + delete process.env['NO_COLOR']; + } else { + process.env['NO_COLOR'] = savedEnv.NO_COLOR; + } + vi.restoreAllMocks(); + }); + + it('prints FatalError messages and exits with their code', async () => { + process.env['NO_COLOR'] = '1'; + + await expect( + handleCriticalError(new FatalError('fatal boom', 42)), + ).rejects.toThrow('process.exit:42'); + + const output = stderr.join(''); + expect(output).toContain('fatal boom'); + expect(output).not.toContain('\x1b[31m'); + }); + + it('prints FatalError messages in red when color is enabled', async () => { + delete process.env['NO_COLOR']; + + await expect( + handleCriticalError(new FatalError('fatal color', 42)), + ).rejects.toThrow('process.exit:42'); + + expect(stderr.join('')).toContain('\x1b[31mfatal color\x1b[0m'); + }); + + it('exits AlreadyReportedError without printing another error', async () => { + await expect( + handleCriticalError(new AlreadyReportedError('already printed', 7)), + ).rejects.toThrow('process.exit:7'); + + expect(stderr.join('')).toBe(''); + }); + + it('prints unexpected errors with the generic critical header', async () => { + await expect( + handleCriticalError(new Error('generic boom')), + ).rejects.toThrow('process.exit:1'); + + const output = stderr.join(''); + expect(output).toContain('An unexpected critical error occurred:'); + expect(output).toContain('generic boom'); + }); + + it('recognizes expected PTY race errors', () => { + expect( + isExpectedPtyRaceError( + Object.assign(new Error('read EIO'), { code: 'EIO' }), + ), + ).toBe(true); + expect(isExpectedPtyRaceError(new Error('read EAGAIN'))).toBe(true); + expect( + isExpectedPtyRaceError( + new Error('Cannot resize a pty that has already exited'), + ), + ).toBe(true); + expect(isExpectedPtyRaceError(new Error('other failure'))).toBe(false); + }); + + it('wires uncaughtException PTY race suppression without exiting', async () => { + let uncaughtHandler: ((error: Error) => void) | undefined; + vi.spyOn(process, 'on').mockImplementation((( + event: string | symbol, + listener: (...args: unknown[]) => void, + ) => { + if (event === 'uncaughtException') { + uncaughtHandler = listener as (error: Error) => void; + } + return process; + }) as typeof process.on); + + await runCliEntryPoint(vi.fn(async () => {})); + + expect(uncaughtHandler).toBeDefined(); + uncaughtHandler?.(Object.assign(new Error('read EIO'), { code: 'EIO' })); + expect(process.exit).not.toHaveBeenCalled(); + expect(stderr.join('')).toBe(''); + }); + + it('routes run failures through the critical error handler', async () => { + const error = new Error('run failed'); + const run = vi.fn(async () => { + throw error; + }); + const handleError = vi.fn(async () => {}); + + await runCliEntryPoint(run, handleError); + + expect(handleError).toHaveBeenCalledWith(error); + }); + + it('reports when the critical error handler itself fails', async () => { + const run = vi.fn(async () => { + throw new Error('run failed'); + }); + const handleError = vi.fn(async () => { + throw new Error('handler failed'); + }); + + await expect(runCliEntryPoint(run, handleError)).rejects.toThrow( + 'process.exit:1', + ); + + const output = stderr.join(''); + expect(output).toContain('Original error:'); + expect(output).toContain('run failed'); + expect(output).toContain('Error handler failed:'); + expect(output).toContain('handler failed'); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000000..9bfaefce18 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,469 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { pathToFileURL } from 'node:url'; +import type { ArgumentsCamelCase, Argv, Options } from 'yargs'; +import { normalizeServeFastPathArgv } from './serve/fast-path-argv.js'; +import { initStartupProfiler } from './utils/startupProfiler.js'; +import { initCpuProfiler } from './utils/cpuProfiler.js'; + +// Preserve the old entrypoint's profiling baseline before route-specific +// dynamic imports or command handling shift startup measurements. +initStartupProfiler(); +initCpuProfiler(); + +type BootstrapRoute = 'serve' | 'mcp' | 'help' | 'version' | 'default'; + +export const TOP_LEVEL_COMMANDS = [ + ['auth', 'Configure authentication (removed)'], + ['channel ', 'Manage messaging channels (Telegram, Discord, etc.)'], + ['extensions ', 'Manage Qwen Code extensions.'], + ['hooks', 'Manage Qwen Code hooks (use /hooks in interactive mode).'], + ['mcp', 'Manage MCP servers'], + [ + 'review ', + 'Internal helpers used by the /review skill (PR worktree setup, context fetch, rules loading, presubmit checks, cleanup)', + ], + [ + 'serve', + 'Run Qwen Code as a local HTTP daemon (Stage 1 experimental: --http-bridge)', + ], + ['sessions ', 'Manage Qwen Code sessions'], +] as const; + +export const MCP_COMMANDS = [ + ['add [args...]', 'Add a server'], + ['remove ', 'Remove a server'], + ['list', 'List all configured MCP servers'], + ['reconnect [server-name]', 'Reconnect to MCP servers'], + ['approve [name]', 'Approve a pending MCP server'], + ['reject [name]', 'Reject a pending MCP server'], +] as const; + +const TOP_LEVEL_HELP_OPTIONS = [ + ['model', { alias: 'm', type: 'string', description: 'Model' }], + [ + 'prompt', + { + alias: 'p', + type: 'string', + description: 'Prompt. Appended to input on stdin (if any).', + }, + ], + [ + 'prompt-interactive', + { + alias: 'i', + type: 'string', + description: + 'Execute the provided prompt and continue in interactive mode', + }, + ], + [ + 'safe-mode', + { + type: 'boolean', + description: + 'Disable all customizations (context files, hooks, extensions, skills, MCP servers) for troubleshooting.', + }, + ], + [ + 'sandbox', + { + alias: 's', + type: 'boolean', + description: 'Run in sandbox?', + }, + ], + [ + 'output-format', + { + alias: 'o', + type: 'string', + choices: ['text', 'json', 'stream-json'], + description: 'The format of the CLI output.', + }, + ], + [ + 'continue', + { + alias: 'c', + type: 'boolean', + description: 'Resume the most recent session for the current project.', + }, + ], + [ + 'resume', + { + alias: 'r', + type: 'string', + description: + 'Resume a specific session by its ID. Use without an ID to show session picker.', + }, + ], +] as const satisfies ReadonlyArray; + +const VALUE_FLAGS = new Set([ + '--model', + '-m', + '--prompt', + '-p', + '--prompt-interactive', + '-i', + '--output-format', + '-o', + '--resume', + '-r', +]); + +function writeStdoutLine(line: string): void { + process.stdout.write(line.endsWith('\n') ? line : `${line}\n`); +} + +function hasFlag( + argv: readonly string[], + long: string, + short: string, +): boolean { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === '--') { + return false; + } + if (VALUE_FLAGS.has(arg)) { + i++; + continue; + } + if (arg === long || arg === short) { + return true; + } + } + return false; +} + +async function buildTopLevelHelpParser() { + const { default: yargs } = await import('yargs'); + const parser = yargs([]) + .scriptName('qwen') + .usage( + 'Usage: qwen [options] [command]\n\nQwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode', + ) + .version(process.env['CLI_VERSION'] || 'unknown') + .alias('v', 'version') + .help() + .alias('h', 'help') + .strict() + .demandCommand(0, 0); + + for (const [option, config] of TOP_LEVEL_HELP_OPTIONS) { + parser.option(option, config); + } + + for (const [command, description] of TOP_LEVEL_COMMANDS) { + parser.command(command, description); + } + + return parser; +} + +function firstPositionalArg(argv: readonly string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === '--') { + return undefined; + } + if (VALUE_FLAGS.has(arg)) { + i++; + continue; + } + if (!arg.startsWith('-')) { + return arg; + } + } + return undefined; +} + +function normalizeMcpFastPathArgv(argv: readonly string[]): readonly string[] { + if (argv[0] === 'mcp' && argv[1] === '--') { + return [argv[0], ...argv.slice(2)]; + } + return argv; +} + +export function resolveBootstrapRoute( + rawArgv: readonly string[], +): BootstrapRoute { + const argv = normalizeServeFastPathArgv(rawArgv); + + if (hasFlag(argv, '--version', '-v')) { + return 'version'; + } + + const firstArg = argv[0]; + if (firstArg === 'serve') { + return 'serve'; + } + if (firstArg === 'mcp') { + return 'mcp'; + } + + const firstPositional = firstPositionalArg(argv); + if (hasFlag(argv, '--help', '-h') && firstPositional === undefined) { + return 'help'; + } + + return 'default'; +} + +async function printTopLevelHelp(): Promise { + const help = await (await buildTopLevelHelpParser()).getHelp(); + writeStdoutLine(help); +} + +function printMcpHelp(): void { + const lines = [ + 'Usage: qwen mcp ', + '', + 'Manage MCP servers', + '', + 'Commands:', + ...MCP_COMMANDS.map( + ([command, description]) => ` qwen mcp ${command} ${description}`, + ), + ]; + writeStdoutLine(lines.join('\n')); +} + +async function printBootstrapVersion(): Promise { + if (process.env['CLI_VERSION']) { + writeStdoutLine(process.env['CLI_VERSION']); + return; + } + + const { getCliVersion } = await import('./utils/version.js'); + writeStdoutLine(await getCliVersion()); +} + +async function runMcpFastPath(rawArgv: readonly string[]): Promise { + const argv = normalizeMcpFastPathArgv(normalizeServeFastPathArgv(rawArgv)); + const hasSubcommand = argv.length > 1 && !argv[1]!.startsWith('-'); + if (!hasSubcommand) { + printMcpHelp(); + return; + } + + const [{ default: yargsInstance }, { mcpCommand }] = await Promise.all([ + import('yargs'), + import('./commands/mcp.js'), + ]); + + const parser = yargsInstance([]) + .scriptName('qwen') + .command(mcpCommand) + .version(false) + .help() + .alias('h', 'help') + .strict() + .strictCommands() + .demandCommand(1, 'You need at least one command before continuing.') + .fail((message: string | null, error: Error | undefined, yargs: Argv) => { + writeStderrLine(message || error?.message || 'Unknown argument error'); + yargs.showHelp(); + process.exitCode = 1; + }) + .exitProcess(false); + + if (hasFlag(argv.slice(2), '--help', '-h')) { + await parseYargsHelp(parser, argv); + return; + } + + await parseYargsCommand(parser, argv); +} + +async function parseYargsHelp( + parser: Argv, + argv: readonly string[], +): Promise { + await new Promise((resolve, reject) => { + parser.parse( + argv, + (error: Error | undefined, _argv: ArgumentsCamelCase, output: string) => { + if (output) { + writeStdoutLine(output); + } + if (error) { + reject(error); + return; + } + resolve(); + }, + ); + }); +} + +async function parseYargsCommand( + parser: Argv, + argv: readonly string[], +): Promise { + await new Promise((resolve) => { + parser.parse( + argv, + (error: Error | undefined, _argv: ArgumentsCamelCase, output: string) => { + if (output) { + writeStdoutLine(output); + } + if (error) { + writeStderrLine(error.message); + process.exitCode = 1; + } + resolve(); + }, + ); + }); +} + +export async function runCliEntry( + rawArgv: readonly string[] = process.argv.slice(2), +): Promise { + const argv = normalizeServeFastPathArgv(rawArgv); + const route = resolveBootstrapRoute(argv); + + if (route === 'version') { + await printBootstrapVersion(); + return; + } + + if (route === 'serve') { + const { tryRunServeFastPath } = await import('./serve/fast-path.js'); + if (await tryRunServeFastPath(argv)) { + return; + } + } else if (route === 'mcp') { + await runMcpFastPath(argv); + return; + } else if (route === 'help') { + await printTopLevelHelp(); + return; + } + + const { main } = await import('./gemini.js'); + await main(); +} + +function getErrnoCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object') { + return undefined; + } + const code = (error as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; +} + +export function isExpectedPtyRaceError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const message = error.message; + const code = getErrnoCode(error); + + if ( + (code === 'EIO' && message.includes('read')) || + message.includes('read EIO') + ) { + return true; + } + + if ( + (code === 'EAGAIN' && message.includes('read')) || + message.includes('read EAGAIN') + ) { + return true; + } + + return ( + message.includes('ioctl(2) failed, EBADF') || + message.includes('Cannot resize a pty that has already exited') + ); +} + +export async function handleCriticalError(error: unknown): Promise { + const [{ FatalError }, { AlreadyReportedError }] = await Promise.all([ + import('@qwen-code/qwen-code-core'), + import('./utils/errors.js'), + ]); + + if (error instanceof FatalError) { + let errorMessage = error.message; + if (!process.env['NO_COLOR']) { + errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; + } + writeStderrLine(errorMessage); + process.exit(error.exitCode); + } + if (error instanceof AlreadyReportedError) { + process.exit(error.exitCode); + } + writeStderrLine('An unexpected critical error occurred:'); + if (error instanceof Error) { + writeStderrLine(error.stack ?? error.message); + } else { + writeStderrLine(String(error)); + } + process.exit(1); +} + +function writeStderrLine(line: string): void { + process.stderr.write(line.endsWith('\n') ? line : `${line}\n`); +} + +export async function runCliEntryPoint( + run: () => Promise = runCliEntry, + handleError: (error: unknown) => Promise = handleCriticalError, +): Promise { + process.on('uncaughtException', (error) => { + if (isExpectedPtyRaceError(error)) { + return; + } + + if (error instanceof Error) { + writeStderrLine(error.stack ?? error.message); + } else { + writeStderrLine(String(error)); + } + process.exit(1); + }); + + try { + await run(); + } catch (error) { + try { + await handleError(error); + } catch (handlerError) { + writeStderrLine('An unexpected critical error occurred:'); + writeStderrLine('Original error:'); + if (error instanceof Error) { + writeStderrLine(error.stack ?? error.message); + } else { + writeStderrLine(String(error)); + } + writeStderrLine('Error handler failed:'); + if (handlerError instanceof Error) { + writeStderrLine(handlerError.stack ?? handlerError.message); + } else { + writeStderrLine(String(handlerError)); + } + process.exit(1); + } + } +} + +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + void runCliEntryPoint(); +} diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index 7814b85e83..918609a5e3 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -363,13 +363,12 @@ afterEach(() => { describe('CLI entry import boundary', () => { it('does not statically import the full gemini entry before the serve fast path can run', () => { - const indexSource = readFileSync('index.ts', 'utf8'); + const cliSource = readFileSync('src/cli.ts', 'utf8'); - expect(indexSource).not.toContain("import './src/gemini.js'"); - expect(indexSource).not.toContain("import { main } from './src/gemini.js'"); - expect(indexSource).not.toContain("process.argv[2] === 'serve'"); - expect(indexSource).toContain('import { isServeFastPathArgv }'); - expect(indexSource).toContain("await import('./src/serve/fast-path.js')"); + expect(cliSource).not.toContain("import './gemini.js'"); + expect(cliSource).not.toContain("import { main } from './gemini.js'"); + expect(cliSource).not.toContain("process.argv[2] === 'serve'"); + expect(cliSource).toContain("await import('./serve/fast-path.js')"); }); it('does not import the full settings loader on the serve fast path', () => { diff --git a/packages/core/src/tools/mcp-retry.test.ts b/packages/core/src/tools/mcp-retry.test.ts index 7229a5bdff..f920f7d817 100644 --- a/packages/core/src/tools/mcp-retry.test.ts +++ b/packages/core/src/tools/mcp-retry.test.ts @@ -198,25 +198,32 @@ describe('retryWithBackoff', () => { expect(fn).toHaveBeenCalledTimes(1); }); - it('uses exponential backoff delay (observed timing)', async () => { - const timestamps: number[] = []; + it('uses exponential backoff delay', async () => { + vi.useFakeTimers(); const fn = vi.fn(async () => { - timestamps.push(Date.now()); throw new Error('ETIMEDOUT'); }); - await expect( - retryWithBackoff(fn, 'test-backoff', { + try { + const promise = retryWithBackoff(fn, 'test-backoff', { maxRetries: 2, baseDelayMs: 50, - }), - ).rejects.toThrow('ETIMEDOUT'); + }); + const errorPromise = promise.catch((error: unknown) => error); - expect(fn).toHaveBeenCalledTimes(3); - const gap1 = timestamps[1]! - timestamps[0]!; - const gap2 = timestamps[2]! - timestamps[1]!; - expect(gap1).toBeGreaterThanOrEqual(50); - expect(gap2).toBeGreaterThanOrEqual(gap1); + await vi.waitFor(() => expect(fn).toHaveBeenCalledTimes(1)); + + await vi.advanceTimersByTimeAsync(50); + await vi.waitFor(() => expect(fn).toHaveBeenCalledTimes(2)); + + await vi.advanceTimersByTimeAsync(100); + const error = await errorPromise; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('ETIMEDOUT'); + expect(fn).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } }); it('succeeds after transient 503 then success', async () => { diff --git a/scripts/cli-entry.js b/scripts/cli-entry.js index 53d44b26de..21a3af1b60 100755 --- a/scripts/cli-entry.js +++ b/scripts/cli-entry.js @@ -13,29 +13,82 @@ * global.gc() is available for the memory-pressure monitor's critical-tier * cleanup. * - * For `qwen serve`: imports cli.js directly in-process, skipping the - * spawnSync overhead (~370ms on EDR-instrumented hosts). The daemon host - * process never calls global.gc() — only its ACP children do, and they - * independently add --expose-gc via spawnChannel.ts. + * For bootstrap fast paths: imports cli.js directly in-process, skipping the + * spawnSync overhead. These paths do not need global.gc(); the normal + * interactive path still relaunches with --expose-gc for the memory-pressure + * monitor. */ -import module from 'node:module'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { dirname, join } from 'node:path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const cliPath = join(__dirname, '..', 'dist', 'cli.js'); - -function isServeCommand() { - return process.argv[2] === 'serve'; +function hasFlag(flag, alias) { + for (const arg of process.argv.slice(2)) { + if (arg === '--') { + return false; + } + if (arg === flag || arg === alias) { + return true; + } + } + return false; } -if (isServeCommand()) { +function isInProcessFastPath() { + const first = process.argv[2]; + if (first === 'serve' || first === 'mcp') { + return true; + } + if (first === undefined || first.startsWith('-')) { + return hasFlag('--help', '-h') || hasFlag('--version', '-v'); + } + return false; +} + +const isTopLevelVersion = + (process.argv[2] === undefined || process.argv[2].startsWith('-')) && + hasFlag('--version', '-v'); + +if (isTopLevelVersion && process.env['CLI_VERSION']) { + process.stdout.write(`${process.env['CLI_VERSION']}\n`); + process.exit(0); +} + +const { existsSync } = await import('node:fs'); +const { fileURLToPath, pathToFileURL } = await import('node:url'); +const { dirname, join } = await import('node:path'); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const cliPathCandidates = [ + join(__dirname, 'cli.js'), + join(__dirname, '..', 'dist', 'cli.js'), +]; +const packageJsonPathCandidates = [ + join(__dirname, 'package.json'), + join(__dirname, '..', 'package.json'), +]; +const cliPath = + cliPathCandidates.find((candidate) => existsSync(candidate)) ?? + cliPathCandidates[0]; +const packageJsonPath = + packageJsonPathCandidates.find((candidate) => existsSync(candidate)) ?? + packageJsonPathCandidates[0]; + +if (isTopLevelVersion) { + try { + const { readFileSync } = await import('node:fs'); + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + process.stdout.write(`${pkg.version || 'unknown'}\n`); + process.exit(0); + } catch { + // Fall through to cli.js, which has its own version fallback. + } +} + +if (isInProcessFastPath()) { + const { default: module } = await import('node:module'); module.enableCompileCache?.(); process.argv[1] = cliPath; await import(pathToFileURL(cliPath).href); } else { + const { spawnSync } = await import('node:child_process'); const result = spawnSync( process.execPath, ['--expose-gc', cliPath, ...process.argv.slice(2)], diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index 7b14fb944e..a0a644b527 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -299,40 +299,9 @@ function writeDistPackageJson( ) { console.log('Creating package.json for distribution...'); - const cliEntryContent = `#!/usr/bin/env node -import module from 'node:module'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { dirname, join } from 'node:path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const cliPath = join(__dirname, 'cli.js'); - -function isServeCommand() { - return process.argv[2] === 'serve'; -} - -if (isServeCommand()) { - module.enableCompileCache?.(); - process.argv[1] = cliPath; - await import(pathToFileURL(cliPath).href); -} else { - const result = spawnSync( - process.execPath, - ['--expose-gc', cliPath, ...process.argv.slice(2)], - { stdio: 'inherit' }, - ); - - if (result.signal) { - process.kill(process.pid, result.signal); - } else { - process.exit(result.status ?? 1); - } -} -`; - const cliEntryPath = path.join(distDir, 'cli-entry.js'); - fs.writeFileSync(cliEntryPath, cliEntryContent, { mode: 0o755 }); + fs.copyFileSync(path.join(__dirname, 'cli-entry.js'), cliEntryPath); + fs.chmodSync(cliEntryPath, 0o755); console.log('Created dist cli-entry.js wrapper'); const rootPackageJson = JSON.parse(