Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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