mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(cli): add bootstrap fast paths (#6188)
* 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>
This commit is contained in:
parent
e6e939e020
commit
2cb0031160
8 changed files with 1168 additions and 206 deletions
|
|
@ -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]',
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
|
|
|||
602
packages/cli/src/cli.test.ts
Normal file
602
packages/cli/src/cli.test.ts
Normal file
|
|
@ -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 <name>',
|
||||
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');
|
||||
});
|
||||
});
|
||||
469
packages/cli/src/cli.ts
Normal file
469
packages/cli/src/cli.ts
Normal file
|
|
@ -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 <command>', 'Manage messaging channels (Telegram, Discord, etc.)'],
|
||||
['extensions <command>', 'Manage Qwen Code extensions.'],
|
||||
['hooks', 'Manage Qwen Code hooks (use /hooks in interactive mode).'],
|
||||
['mcp', 'Manage MCP servers'],
|
||||
[
|
||||
'review <command>',
|
||||
'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 <command>', 'Manage Qwen Code sessions'],
|
||||
] as const;
|
||||
|
||||
export const MCP_COMMANDS = [
|
||||
['add <name> <commandOrUrl> [args...]', 'Add a server'],
|
||||
['remove <name>', '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<readonly [string, Options]>;
|
||||
|
||||
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<void> {
|
||||
const help = await (await buildTopLevelHelpParser()).getHelp();
|
||||
writeStdoutLine(help);
|
||||
}
|
||||
|
||||
function printMcpHelp(): void {
|
||||
const lines = [
|
||||
'Usage: qwen mcp <command>',
|
||||
'',
|
||||
'Manage MCP servers',
|
||||
'',
|
||||
'Commands:',
|
||||
...MCP_COMMANDS.map(
|
||||
([command, description]) => ` qwen mcp ${command} ${description}`,
|
||||
),
|
||||
];
|
||||
writeStdoutLine(lines.join('\n'));
|
||||
}
|
||||
|
||||
async function printBootstrapVersion(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await new Promise<void>((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<void> {
|
||||
await new Promise<void>((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<void> {
|
||||
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<void> {
|
||||
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<void> = runCliEntry,
|
||||
handleError: (error: unknown) => Promise<void> = handleCriticalError,
|
||||
): Promise<void> {
|
||||
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();
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue