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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,10 @@ warning severity, otherwise `ok`. Issue codes are stable and include
`session_capacity_high`, `connection_capacity_high`, `pending_permissions`,
`acp_channel_down`, `preflight_error`, `mcp_budget_warning`,
`mcp_budget_exhausted`, `rate_limit_hits`, and
`workspace_status_unavailable`.
`workspace_status_unavailable`. During the short window after the listener is
ready but before the full runtime is mounted, `/daemon/status` may report
`daemon_runtime_starting`; if the async runtime mount fails, it reports
`daemon_runtime_failed` while non-status runtime routes return `503`.

Security: the response never includes bearer tokens, client ids, full ACP
connection ids, device-flow user codes, or verification URLs. `summary` omits
Expand Down
67 changes: 50 additions & 17 deletions packages/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

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();
Expand All @@ -16,14 +17,12 @@ import { initCpuProfiler } from './src/utils/cpuProfiler.js';
// QWEN_CODE_CPU_PROFILE=1, capturing as much of the startup as possible.
initCpuProfiler();

import './src/gemini.js';
import { main } from './src/gemini.js';
import { FatalError } from '@qwen-code/qwen-code-core';
import { AlreadyReportedError } from './src/utils/errors.js';
import { writeStderrLine } from './src/utils/stdioHelpers.js';

// --- Global Entry Point ---

function writeStderrLine(line: string): void {
Comment thread
doudouOUC marked this conversation as resolved.
process.stderr.write(`${line}\n`);
}

// Suppress known race conditions in @lydell/node-pty.
//
// PTY errors that are expected due to timing races between process exit
Expand Down Expand Up @@ -76,20 +75,22 @@ const isExpectedPtyRaceError = (error: unknown): boolean => {
return false;
};

process.on('uncaughtException', (error) => {
if (isExpectedPtyRaceError(error)) {
return;
async function runCliEntry(): Promise<void> {
if (isServeFastPathArgv(process.argv.slice(2))) {
const { tryRunServeFastPath } = await import('./src/serve/fast-path.js');
if (await tryRunServeFastPath()) return;
}

if (error instanceof Error) {
writeStderrLine(error.stack ?? error.message);
} else {
writeStderrLine(String(error));
}
process.exit(1);
});
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'),
]);

main().catch((error) => {
if (error instanceof FatalError) {
let errorMessage = error.message;
if (!process.env['NO_COLOR']) {
Expand All @@ -114,4 +115,36 @@ main().catch((error) => {
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) => {
Comment thread
doudouOUC marked this conversation as resolved.
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);
});
});
13 changes: 5 additions & 8 deletions packages/cli/src/commands/extensions/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { updateCommand, handleUpdate } from './update.js';
import yargs from 'yargs';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { ExtensionUpdateState } from '@qwen-code/qwen-code-core';

const mockGetLoadedExtensions = vi.hoisted(() => vi.fn());
const mockUpdateExtension = vi.hoisted(() => vi.fn());
Expand All @@ -28,20 +28,17 @@ vi.mock('./utils.js', () => ({

vi.mock('@qwen-code/qwen-code-core', () => ({
checkForExtensionUpdate: mockCheckForExtensionUpdate,
}));

vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));

vi.mock('../../ui/state/extensions.js', () => ({
ExtensionUpdateState: {
UPDATE_AVAILABLE: 'update available',
UP_TO_DATE: 'up to date',
ERROR: 'error',
},
}));

vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));

vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: mockWriteStdoutLine,
writeStderrLine: mockWriteStderrLine,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/extensions/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import type { CommandModule } from 'yargs';
import { getErrorMessage } from '../../utils/errors.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import {
checkForExtensionUpdate,
ExtensionUpdateState,
type ExtensionUpdateInfo,
} from '@qwen-code/qwen-code-core';
import { getExtensionManager } from './utils.js';
Expand Down
206 changes: 205 additions & 1 deletion packages/cli/src/commands/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { spawn, spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import yargs, { type Argv } from 'yargs';
import { serveCommand, maybeOpenWebShellBrowser } from './serve.js';
Expand All @@ -20,7 +24,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
shouldLaunchBrowser: mockShouldLaunchBrowser,
};
});
vi.mock('../serve/index.js', () => ({
vi.mock('../serve/run-qwen-serve.js', () => ({
runQwenServe: mockRunQwenServe,
}));

Expand Down Expand Up @@ -145,6 +149,9 @@ describe('maybeOpenWebShellBrowser', () => {
vi.clearAllMocks();
mockShouldLaunchBrowser.mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});

const firstOpenedUrl = () =>
String(mockOpenBrowserSecurely.mock.calls[0]?.[0]);
Expand Down Expand Up @@ -196,6 +203,28 @@ describe('maybeOpenWebShellBrowser', () => {
expect(firstOpenedUrl()).not.toContain('?token=');
});

it('skips --open when the runtime failed to mount', async () => {
const stderrWrites: string[] = [];
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
stderrWrites.push(String(chunk));
return true;
});

await maybeOpenWebShellBrowser(
{
url: 'http://127.0.0.1:4170/',
webShellMounted: true,
runtimeReady: Promise.reject(new Error('runtime boom')),
},
true,
);

expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
expect(stderrWrites.join('')).toContain(
'qwen serve: Web Shell runtime not ready; skipping --open: runtime boom',
);
});

it('swallows openBrowserSecurely failures (never throws)', async () => {
mockOpenBrowserSecurely.mockRejectedValueOnce(new Error('boom'));
await expect(
Expand All @@ -206,3 +235,178 @@ describe('maybeOpenWebShellBrowser', () => {
).resolves.toBeUndefined();
});
});

describe('serve startup import boundary', () => {
it('reaches listening through the dev entrypoint without loading interactive Ink internals first', async () => {
const workspace = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-import-boundary-')),
);
const qwenHome = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-import-boundary-home-')),
);
const root = path.resolve(process.cwd(), '../..');
const childEnv: NodeJS.ProcessEnv = {
...process.env,
QWEN_CODE_NO_RELAUNCH: '1',
QWEN_CODE_SUPPRESS_YOLO_WARNING: '1',
QWEN_HOME: qwenHome,
QWEN_RUNTIME_DIR: workspace,
QWEN_SERVE_RATE_LIMIT: '0',
};
delete childEnv['VITEST_WORKER_ID'];
const child = spawn(
process.execPath,
[
path.join(root, 'scripts/dev.js'),
'serve',
'--port',
'0',
'--hostname',
'127.0.0.1',
'--workspace',
workspace,
'--no-web',
'--no-open',
'--rate-limit-prompt',
'0',
'--rate-limit-window-ms',
'1',
],
{
cwd: root,
detached: process.platform !== 'win32',
env: childEnv,
stdio: ['ignore', 'pipe', 'pipe'],
},
);

let stdout = '';
let stderr = '';
let childExited = false;
const exited = new Promise<void>((resolve) => {
child.once('exit', () => {
childExited = true;
resolve();
});
});
const waitForExit = (ms: number) =>
Promise.race([
exited,
new Promise<'timeout'>((resolve) => setTimeout(resolve, ms, 'timeout')),
]);
const cleanup = async () => {
if (child.pid === undefined) return;
const childPid = child.pid;
const signalProcessTree = (signal: NodeJS.Signals) => {
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(childPid), '/T', '/F']);
return;
}
process.kill(-childPid, signal);
};
try {
signalProcessTree('SIGTERM');
} catch {
// Process may have already exited.
}
if (!childExited) {
await waitForExit(2_000);
}
if (process.platform !== 'win32') {
try {
signalProcessTree('SIGKILL');
} catch {
// Process may have already exited.
}
if (!childExited) {
await waitForExit(2_000);
}
}
};
const removeTempDir = async (dir: string) => {
for (let attempt = 0; attempt < 5; attempt++) {
try {
fs.rmSync(dir, { recursive: true, force: true });
return;
} catch (err) {
if (attempt === 4) throw err;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
};
const processGroupHasMembers = (pgid: number): boolean => {
if (process.platform === 'win32') return false;
const result = spawnSync('ps', ['-o', 'pid=', '-g', String(pgid)], {
encoding: 'utf8',
});
if (result.status !== 0) return false;
return result.stdout
.split(/\s+/)
.some((pid) => pid.length > 0 && Number(pid) > 0);
};
const waitForProcessGroupExit = async (pgid: number) => {
if (process.platform === 'win32') return;
for (let attempt = 0; attempt < 20; attempt++) {
if (!processGroupHasMembers(pgid)) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`serve process group ${pgid} did not exit`);
};

try {
const reachedListening = await new Promise<boolean>((resolve, reject) => {
const timeout = setTimeout(() => {
void cleanup();
reject(
new Error(
`serve did not reach listening\nstdout:\n${stdout}\nstderr:\n${stderr}`,
),
);
}, 15_000);

child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
if (stdout.includes('qwen serve listening on')) {
clearTimeout(timeout);
void cleanup();
resolve(true);
}
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
if (
stderr.includes('ERR_PACKAGE_PATH_NOT_EXPORTED') ||
stderr.includes('ink/dom') ||
stderr.includes('ink/components/CursorContext')
) {
clearTimeout(timeout);
void cleanup();
reject(new Error(stderr));
}
});
child.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
child.on('exit', (code, signal) => {
if (stdout.includes('qwen serve listening on')) return;
clearTimeout(timeout);
reject(
new Error(
`serve exited before listening: code=${code} signal=${signal}\nstdout:\n${stdout}\nstderr:\n${stderr}`,
),
);
});
});

expect(reachedListening).toBe(true);
} finally {
await cleanup();
if (child.pid !== undefined) {
await waitForProcessGroupExit(child.pid);
}
await removeTempDir(workspace);
await removeTempDir(qwenHome);
}
}, 20_000);
});
Loading
Loading