mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(cli): Keep serve health responsive before runtime load (#6013)
* fix(cli): keep serve health responsive before runtime load Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix deferred runtime route startup (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6013) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
e1ddcdc2a7
commit
f37c64c7f2
8 changed files with 1190 additions and 55 deletions
167
packages/cli/src/serve/fast-path-open.test.ts
Normal file
167
packages/cli/src/serve/fast-path-open.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const originalQwenHome = process.env['QWEN_HOME'];
|
||||
const originalSystemSettingsPath =
|
||||
process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
||||
const originalSystemDefaultsPath =
|
||||
process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'];
|
||||
const originalTrustedFoldersPath =
|
||||
process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'];
|
||||
|
||||
describe('serve fast path --open import boundary', () => {
|
||||
let tempQwenHome: string | undefined;
|
||||
|
||||
function useTempQwenHome(): void {
|
||||
tempQwenHome = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-fast-path-open-')),
|
||||
);
|
||||
process.env['QWEN_HOME'] = tempQwenHome;
|
||||
process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'] = path.join(
|
||||
tempQwenHome,
|
||||
'system-settings.json',
|
||||
);
|
||||
process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'] = path.join(
|
||||
tempQwenHome,
|
||||
'system-defaults.json',
|
||||
);
|
||||
process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = path.join(
|
||||
tempQwenHome,
|
||||
'trustedFolders.json',
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.doUnmock('./run-qwen-serve.js');
|
||||
vi.doUnmock('../commands/serve.js');
|
||||
vi.resetModules();
|
||||
if (originalQwenHome === undefined) {
|
||||
delete process.env['QWEN_HOME'];
|
||||
} else {
|
||||
process.env['QWEN_HOME'] = originalQwenHome;
|
||||
}
|
||||
if (originalSystemSettingsPath === undefined) {
|
||||
delete process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
||||
} else {
|
||||
process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'] =
|
||||
originalSystemSettingsPath;
|
||||
}
|
||||
if (originalSystemDefaultsPath === undefined) {
|
||||
delete process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'];
|
||||
} else {
|
||||
process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'] =
|
||||
originalSystemDefaultsPath;
|
||||
}
|
||||
if (originalTrustedFoldersPath === undefined) {
|
||||
delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'];
|
||||
} else {
|
||||
process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] =
|
||||
originalTrustedFoldersPath;
|
||||
}
|
||||
if (tempQwenHome) {
|
||||
fs.rmSync(tempQwenHome, { recursive: true, force: true });
|
||||
tempQwenHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('defers importing the full serve command opener until runtime is ready', async () => {
|
||||
useTempQwenHome();
|
||||
|
||||
let resolveRuntime: (() => void) | undefined;
|
||||
const runtimeReady = new Promise<void>((resolve) => {
|
||||
resolveRuntime = resolve;
|
||||
});
|
||||
const runQwenServe = vi.fn(async () => ({
|
||||
runtimeReady,
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
let serveCommandImported = false;
|
||||
const openBrowser = vi.fn(async () => undefined);
|
||||
vi.doMock('./run-qwen-serve.js', () => ({ runQwenServe }));
|
||||
vi.doMock('../commands/serve.js', () => {
|
||||
serveCommandImported = true;
|
||||
return { maybeOpenWebShellBrowser: openBrowser };
|
||||
});
|
||||
|
||||
const { tryRunServeFastPath } = await import('./fast-path.js');
|
||||
void tryRunServeFastPath([
|
||||
'serve',
|
||||
'--port',
|
||||
'0',
|
||||
'--hostname',
|
||||
'127.0.0.1',
|
||||
'--open',
|
||||
'--no-web',
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => expect(runQwenServe).toHaveBeenCalledTimes(1));
|
||||
expect(runQwenServe).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ deferRuntimeUntilFirstHealth: false }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(serveCommandImported).toBe(false);
|
||||
|
||||
resolveRuntime?.();
|
||||
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledTimes(1));
|
||||
expect(serveCommandImported).toBe(true);
|
||||
});
|
||||
|
||||
it('skips importing the full serve command opener when runtime startup fails', async () => {
|
||||
useTempQwenHome();
|
||||
|
||||
let rejectRuntime: ((err: Error) => void) | undefined;
|
||||
const runtimeReady = new Promise<void>((_resolve, reject) => {
|
||||
rejectRuntime = reject;
|
||||
});
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const runQwenServe = vi.fn(async () => ({
|
||||
runtimeReady,
|
||||
close,
|
||||
}));
|
||||
let serveCommandImported = false;
|
||||
const openBrowser = vi.fn(async () => undefined);
|
||||
vi.doMock('./run-qwen-serve.js', () => ({ runQwenServe }));
|
||||
vi.doMock('../commands/serve.js', () => {
|
||||
serveCommandImported = true;
|
||||
return { maybeOpenWebShellBrowser: openBrowser };
|
||||
});
|
||||
vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
||||
throw new Error(`process.exit ${code}`);
|
||||
}) as typeof process.exit);
|
||||
|
||||
const { tryRunServeFastPath } = await import('./fast-path.js');
|
||||
const fastPathPromise = tryRunServeFastPath([
|
||||
'serve',
|
||||
'--port',
|
||||
'0',
|
||||
'--hostname',
|
||||
'127.0.0.1',
|
||||
'--open',
|
||||
'--no-web',
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => expect(runQwenServe).toHaveBeenCalledTimes(1));
|
||||
expect(runQwenServe).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ deferRuntimeUntilFirstHealth: false }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(serveCommandImported).toBe(false);
|
||||
|
||||
rejectRuntime?.(new Error('runtime boom'));
|
||||
await expect(fastPathPromise).rejects.toThrow('process.exit 1');
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
expect(serveCommandImported).toBe(false);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -37,10 +37,12 @@ import {
|
|||
getGlobalQwenDirLite,
|
||||
SETTINGS_DIRECTORY_NAME,
|
||||
} from '../config/storage-paths-lite.js';
|
||||
import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js';
|
||||
import {
|
||||
resetTrustedFoldersForTesting,
|
||||
TrustLevel,
|
||||
} from '../config/trustedFolders.js';
|
||||
import { HEADLESS_YOLO_NO_SANDBOX_WARNING } from '../utils/headlessSafetyWarnings.js';
|
||||
import * as runQwenServeModule from './run-qwen-serve.js';
|
||||
import type { ServeFastPathSettings } from './fast-path-settings.js';
|
||||
import type { Settings } from '../config/settingsSchema.js';
|
||||
|
|
@ -378,6 +380,29 @@ describe('CLI entry import boundary', () => {
|
|||
expect(fastPathSource).not.toContain('@qwen-code/qwen-code-core');
|
||||
expect(fastPathSource).toContain('bootSettings: settings');
|
||||
expect(fastPathSource).toContain('resolveOnListen: true');
|
||||
expect(fastPathSource).toContain(
|
||||
'deferRuntimeUntilFirstHealth: !parsed.open',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the shared headless yolo warning helper on the serve fast path', () => {
|
||||
const fastPathSource = readFileSync('src/serve/fast-path.ts', 'utf8');
|
||||
|
||||
expect(fastPathSource).toContain('getHeadlessYoloSafetyWarning');
|
||||
expect(fastPathSource).not.toContain(
|
||||
"settings.tools?.approvalMode === 'yolo'",
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps headless yolo warning helper free of runtime core imports', () => {
|
||||
const helperSource = readFileSync(
|
||||
'src/utils/headlessSafetyWarnings.ts',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(helperSource).not.toMatch(
|
||||
/import\s+(?!type\b)[^;]*from ['"]@qwen-code\/qwen-code-core['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps settings free of UI imports used before serve can listen', () => {
|
||||
|
|
@ -796,6 +821,35 @@ describe('serve fast path environment bootstrap', () => {
|
|||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not report startup failure when runtime startup is cancelled by close', async () => {
|
||||
const stderrWrites: string[] = [];
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
||||
stderrWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(((
|
||||
code?: string | number | null,
|
||||
) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
}) as typeof process.exit);
|
||||
|
||||
await expect(
|
||||
waitForServeRuntimeOrExit({
|
||||
runtimeReady: Promise.reject(
|
||||
new Error(RUNTIME_STARTUP_CANCELLED_MESSAGE),
|
||||
),
|
||||
close,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(stderrWrites.join('')).not.toContain(
|
||||
'runtime startup failed after listener was ready',
|
||||
);
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates rate limit env after settings bootstrap enables rate limiting', async () => {
|
||||
useTempQwenHome();
|
||||
tempWorkspace = realpathSync(
|
||||
|
|
@ -874,6 +928,66 @@ describe('serve fast path environment bootstrap', () => {
|
|||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('keeps headless yolo warning best-effort after listening', async () => {
|
||||
const originalSandbox = process.env['SANDBOX'];
|
||||
const originalSuppress = process.env['QWEN_CODE_SUPPRESS_YOLO_WARNING'];
|
||||
delete process.env['SANDBOX'];
|
||||
delete process.env['QWEN_CODE_SUPPRESS_YOLO_WARNING'];
|
||||
const qwenHome = useTempQwenHome();
|
||||
writeFileSync(
|
||||
join(qwenHome, 'settings.json'),
|
||||
JSON.stringify({ tools: { approvalMode: 'yolo', sandbox: false } }),
|
||||
);
|
||||
const runtimeReady = Promise.reject(new Error('runtime boom'));
|
||||
void runtimeReady.catch(() => undefined);
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
vi.spyOn(runQwenServeModule, 'runQwenServe').mockResolvedValue({
|
||||
runtimeReady,
|
||||
close,
|
||||
} as unknown as Awaited<
|
||||
ReturnType<typeof runQwenServeModule.runQwenServe>
|
||||
>);
|
||||
const stderrWrites: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
||||
const text = String(chunk);
|
||||
stderrWrites.push(text);
|
||||
if (text.includes(HEADLESS_YOLO_NO_SANDBOX_WARNING)) {
|
||||
throw new Error('stderr closed');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process, 'exit').mockImplementation(((
|
||||
code?: string | number | null,
|
||||
) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
}) as typeof process.exit);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
tryRunServeFastPath(['serve', '--port', '0', '--no-open', '--no-web']),
|
||||
).rejects.toThrow('process.exit(1)');
|
||||
|
||||
expect(stderrWrites.join('')).toContain(HEADLESS_YOLO_NO_SANDBOX_WARNING);
|
||||
expect(stderrWrites.join('')).toContain(
|
||||
'qwen serve: runtime startup failed after listener was ready: runtime boom',
|
||||
);
|
||||
expect(stderrWrites.join('')).not.toContain('qwen serve: stderr closed');
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
if (originalSandbox === undefined) {
|
||||
delete process.env['SANDBOX'];
|
||||
} else {
|
||||
process.env['SANDBOX'] = originalSandbox;
|
||||
}
|
||||
if (originalSuppress === undefined) {
|
||||
delete process.env['QWEN_CODE_SUPPRESS_YOLO_WARNING'];
|
||||
} else {
|
||||
process.env['QWEN_CODE_SUPPRESS_YOLO_WARNING'] = originalSuppress;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects malformed user settings so the full settings loader can handle it', async () => {
|
||||
const qwenHome = useTempQwenHome();
|
||||
writeFileSync(join(qwenHome, 'settings.json'), '{');
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
import type { RunHandle } from './run-qwen-serve.js';
|
||||
import { normalizeServeFastPathArgv } from './fast-path-argv.js';
|
||||
import type { ServeFastPathSettings } from './fast-path-settings.js';
|
||||
import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js';
|
||||
import type { ServeOptions } from './types.js';
|
||||
import { getHeadlessYoloSafetyWarning } from '../utils/headlessSafetyWarnings.js';
|
||||
|
||||
type McpBudgetMode = NonNullable<ServeOptions['mcpBudgetMode']>;
|
||||
|
||||
|
|
@ -201,6 +203,12 @@ export async function waitForServeRuntimeOrExit(
|
|||
try {
|
||||
await handle.runtimeReady;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message === RUNTIME_STARTUP_CANCELLED_MESSAGE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
writeStderrLine(
|
||||
`qwen serve: runtime startup failed after listener was ready: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
|
|
@ -389,31 +397,27 @@ async function maybeOpenWebShellBrowser(
|
|||
open: boolean,
|
||||
): Promise<void> {
|
||||
if (!open) return;
|
||||
try {
|
||||
await handle.runtimeReady;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { maybeOpenWebShellBrowser: openBrowser } = await import(
|
||||
'../commands/serve.js'
|
||||
);
|
||||
await openBrowser(handle, true);
|
||||
}
|
||||
|
||||
async function emitHeadlessYoloWarning(
|
||||
function emitHeadlessYoloWarning(
|
||||
settings: ServeFastPathSettings | undefined,
|
||||
) {
|
||||
): void {
|
||||
if (!settings) return;
|
||||
try {
|
||||
const { HEADLESS_YOLO_NO_SANDBOX_WARNING } = await import(
|
||||
'../utils/headlessSafetyWarnings.js'
|
||||
);
|
||||
const suppress = process.env['QWEN_CODE_SUPPRESS_YOLO_WARNING'];
|
||||
if (
|
||||
settings.tools?.approvalMode === 'yolo' &&
|
||||
!settings.tools?.sandbox &&
|
||||
!process.env['SANDBOX'] &&
|
||||
!isTruthyEnv(suppress)
|
||||
) {
|
||||
writeStderrLine(HEADLESS_YOLO_NO_SANDBOX_WARNING);
|
||||
}
|
||||
} catch {
|
||||
// Keep the warning best-effort, matching the yargs serve handler.
|
||||
const warning = getHeadlessYoloSafetyWarning({
|
||||
getApprovalMode: () => settings.tools?.approvalMode,
|
||||
getSandbox: () => settings.tools?.sandbox,
|
||||
});
|
||||
if (warning) {
|
||||
writeStderrLine(warning);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -491,8 +495,13 @@ export async function tryRunServeFastPath(
|
|||
handle = await runQwenServe(parsed.options, {
|
||||
...(settings ? { bootSettings: settings } : {}),
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: !parsed.open,
|
||||
});
|
||||
void emitHeadlessYoloWarning(settings);
|
||||
try {
|
||||
emitHeadlessYoloWarning(settings);
|
||||
} catch {
|
||||
// Keep the warning best-effort, matching the yargs serve handler.
|
||||
}
|
||||
await maybeOpenWebShellBrowser(handle, parsed.open);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import * as fs from 'node:fs';
|
|||
import { createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import {
|
||||
createLazyBridgeProxy,
|
||||
extractContextFilename,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
validatePolicyConfig,
|
||||
waitForRuntimeStartingForShutdown,
|
||||
} from './run-qwen-serve.js';
|
||||
import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js';
|
||||
import * as acpBridge from '@qwen-code/acp-bridge/bridge';
|
||||
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
|
||||
import type {
|
||||
|
|
@ -44,6 +46,22 @@ const BASE_BRIDGE_SNAPSHOT: BridgeDaemonStatusSnapshot = {
|
|||
sessions: [],
|
||||
};
|
||||
|
||||
function makeRuntimeBridge(): HttpAcpBridge {
|
||||
return {
|
||||
spawnOrAttach: vi.fn(),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
killAllSync: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
getAllSessions: vi.fn().mockReturnValue([]),
|
||||
publishWorkspaceEvent: vi.fn(),
|
||||
getEventRing: vi.fn().mockReturnValue({ getAll: () => [] }),
|
||||
resume: vi.fn(),
|
||||
preheat: vi.fn().mockResolvedValue(undefined),
|
||||
getDaemonStatusSnapshot: vi.fn().mockReturnValue(BASE_BRIDGE_SNAPSHOT),
|
||||
isChannelLive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as HttpAcpBridge;
|
||||
}
|
||||
|
||||
const mockCreateSpawnChannelFactoryOptions = vi.hoisted(
|
||||
() => [] as Array<Record<string, unknown>>,
|
||||
);
|
||||
|
|
@ -708,19 +726,7 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockReturnValue(
|
||||
telemetryPromise,
|
||||
);
|
||||
const bridge = {
|
||||
spawnOrAttach: vi.fn(),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
killAllSync: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
getAllSessions: vi.fn().mockReturnValue([]),
|
||||
publishWorkspaceEvent: vi.fn(),
|
||||
getEventRing: vi.fn().mockReturnValue({ getAll: () => [] }),
|
||||
resume: vi.fn(),
|
||||
preheat: vi.fn().mockResolvedValue(undefined),
|
||||
getDaemonStatusSnapshot: vi.fn().mockReturnValue(BASE_BRIDGE_SNAPSHOT),
|
||||
isChannelLive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as HttpAcpBridge;
|
||||
const bridge = makeRuntimeBridge();
|
||||
vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
|
|
@ -753,6 +759,664 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps health responsive before starting deferred runtime work', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-first-')),
|
||||
);
|
||||
const logBaseDir = path.join(tmpDir, 'debug');
|
||||
const resolveTelemetrySettings = vi
|
||||
.spyOn(qwenCore, 'resolveTelemetrySettings')
|
||||
.mockResolvedValue({
|
||||
enabled: false,
|
||||
sensitiveSpanAttributeMaxLength: 1024 * 1024,
|
||||
});
|
||||
const bridge = makeRuntimeBridge();
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
daemonLogBaseDir: logBaseDir,
|
||||
},
|
||||
);
|
||||
|
||||
let closed = false;
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(resolveTelemetrySettings).not.toHaveBeenCalled();
|
||||
expect(createBridge).not.toHaveBeenCalled();
|
||||
const healthRes = await fetch(`${handle.url}/health`);
|
||||
expect(healthRes.status).toBe(200);
|
||||
expect(await healthRes.json()).toEqual({ status: 'ok' });
|
||||
|
||||
await vi.waitFor(() => expect(createBridge).toHaveBeenCalledTimes(1), {
|
||||
timeout: 500,
|
||||
});
|
||||
expect(resolveTelemetrySettings).toHaveBeenCalledTimes(1);
|
||||
await expect(handle.runtimeReady).resolves.toBeUndefined();
|
||||
await handle.close();
|
||||
closed = true;
|
||||
|
||||
const daemonDir = path.join(logBaseDir, 'daemon');
|
||||
const [logFile] = fs
|
||||
.readdirSync(daemonDir)
|
||||
.filter((fileName) => fileName.endsWith('.log'));
|
||||
expect(logFile).toBeDefined();
|
||||
const logContent = fs.readFileSync(
|
||||
path.join(daemonDir, logFile!),
|
||||
'utf8',
|
||||
);
|
||||
expect(logContent).toContain(
|
||||
'deferred runtime: health timer fired, starting',
|
||||
);
|
||||
} finally {
|
||||
if (!closed) {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('starts deferred runtime once for duplicate health probes', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-dedupe-')),
|
||||
);
|
||||
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
|
||||
enabled: false,
|
||||
sensitiveSpanAttributeMaxLength: 1024 * 1024,
|
||||
});
|
||||
const bridge = makeRuntimeBridge();
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
expect(createBridge).not.toHaveBeenCalled();
|
||||
const [firstHealthRes, secondHealthRes] = await Promise.all([
|
||||
fetch(`${handle.url}/health`),
|
||||
fetch(`${handle.url}/health`),
|
||||
]);
|
||||
expect(firstHealthRes.status).toBe(200);
|
||||
expect(secondHealthRes.status).toBe(200);
|
||||
expect(await firstHealthRes.json()).toEqual({ status: 'ok' });
|
||||
expect(await secondHealthRes.json()).toEqual({ status: 'ok' });
|
||||
|
||||
await vi.waitFor(() => expect(createBridge).toHaveBeenCalledTimes(1), {
|
||||
timeout: 500,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(createBridge).toHaveBeenCalledTimes(1);
|
||||
await expect(handle.runtimeReady).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('starts deferred runtime for the first runtime route and serves that request', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-route-start-')),
|
||||
);
|
||||
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
|
||||
enabled: false,
|
||||
sensitiveSpanAttributeMaxLength: 1024 * 1024,
|
||||
});
|
||||
const bridge = makeRuntimeBridge();
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
vi.spyOn(serverModule, 'createServeApp').mockImplementation(() => {
|
||||
const app = express();
|
||||
app.post('/session', (_req, res) => {
|
||||
res.status(201).json({ sessionId: 'session-1' });
|
||||
});
|
||||
return app;
|
||||
});
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
expect(createBridge).not.toHaveBeenCalled();
|
||||
const res = await fetch(`${handle.url}/session`, { method: 'POST' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(await res.json()).toEqual({ sessionId: 'session-1' });
|
||||
expect(createBridge).toHaveBeenCalledTimes(1);
|
||||
await expect(handle.runtimeReady).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unauthenticated deferred runtime routes before starting runtime', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-route-auth-')),
|
||||
);
|
||||
const bridge = makeRuntimeBridge();
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
vi.spyOn(serverModule, 'createServeApp').mockImplementation(() => {
|
||||
const app = express();
|
||||
app.post('/session', (_req, res) => {
|
||||
res.status(201).json({ sessionId: 'session-1' });
|
||||
});
|
||||
return app;
|
||||
});
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
token: 'secret-token',
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${handle.url}/session`, { method: 'POST' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ error: 'Unauthorized' });
|
||||
expect(createBridge).not.toHaveBeenCalled();
|
||||
|
||||
const authorizedRes = await fetch(`${handle.url}/session`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer secret-token' },
|
||||
});
|
||||
expect(authorizedRes.status).toBe(201);
|
||||
expect(await authorizedRes.json()).toEqual({ sessionId: 'session-1' });
|
||||
expect(createBridge).toHaveBeenCalledTimes(1);
|
||||
await expect(handle.runtimeReady).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows deferred runtime CORS preflight without auth or runtime startup', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-preflight-')),
|
||||
);
|
||||
const bridge = makeRuntimeBridge();
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
token: 'secret-token',
|
||||
allowOrigins: ['http://localhost:5173'],
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${handle.url}/session/foo/prompt`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: 'http://localhost:5173',
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'authorization,content-type',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe(
|
||||
'http://localhost:5173',
|
||||
);
|
||||
expect(res.headers.get('access-control-allow-methods')).toContain('POST');
|
||||
expect(createBridge).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not start deferred runtime for unsupported bootstrap route methods', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-bootstrap-method-')),
|
||||
);
|
||||
const bridge = makeRuntimeBridge();
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${handle.url}/health`, { method: 'POST' });
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.json()).toMatchObject({
|
||||
code: 'daemon_runtime_starting',
|
||||
});
|
||||
expect(createBridge).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('serves trailing-slash bootstrap health without waiting for deferred runtime', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-bootstrap-trailing-')),
|
||||
);
|
||||
let resolveTelemetry:
|
||||
| ((settings: qwenCore.ResolvedTelemetrySettings) => void)
|
||||
| undefined;
|
||||
const telemetryPromise = new Promise<qwenCore.ResolvedTelemetrySettings>(
|
||||
(resolve) => {
|
||||
resolveTelemetry = resolve;
|
||||
},
|
||||
);
|
||||
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockReturnValue(
|
||||
telemetryPromise,
|
||||
);
|
||||
const bridge = makeRuntimeBridge();
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await Promise.race([
|
||||
fetch(`${handle.url}/health/`),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Trailing-slash health timed out')),
|
||||
200,
|
||||
),
|
||||
),
|
||||
]);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ status: 'ok' });
|
||||
expect(createBridge).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
resolveTelemetry?.({
|
||||
enabled: false,
|
||||
sensitiveSpanAttributeMaxLength: 1024 * 1024,
|
||||
});
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports deferred runtime startup failure for the triggering runtime route', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-route-fail-')),
|
||||
);
|
||||
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
|
||||
enabled: false,
|
||||
sensitiveSpanAttributeMaxLength: 1024 * 1024,
|
||||
});
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('runtime boom');
|
||||
});
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${handle.url}/session`, { method: 'POST' });
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.json()).toEqual({
|
||||
error: 'Daemon runtime failed to start',
|
||||
code: 'daemon_runtime_failed',
|
||||
});
|
||||
expect(createBridge).toHaveBeenCalledTimes(1);
|
||||
await expect(handle.runtimeReady).rejects.toThrow('runtime boom');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('starts deferred runtime on fallback when no health probe arrives', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-fallback-')),
|
||||
);
|
||||
const bridge = makeRuntimeBridge();
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
expect(createBridge).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(createBridge).toHaveBeenCalledTimes(1), {
|
||||
timeout: 1500,
|
||||
});
|
||||
await expect(handle.runtimeReady).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not start deferred runtime after close before first health', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-close-')),
|
||||
);
|
||||
const logBaseDir = path.join(tmpDir, 'debug');
|
||||
const bridge = makeRuntimeBridge();
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
daemonLogBaseDir: logBaseDir,
|
||||
},
|
||||
);
|
||||
|
||||
await handle.close();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
expect(createBridge).not.toHaveBeenCalled();
|
||||
await expect(handle.runtimeReady).rejects.toThrow(
|
||||
RUNTIME_STARTUP_CANCELLED_MESSAGE,
|
||||
);
|
||||
const daemonDir = path.join(logBaseDir, 'daemon');
|
||||
const [logFile] = fs
|
||||
.readdirSync(daemonDir)
|
||||
.filter((fileName) => fileName.endsWith('.log'));
|
||||
expect(logFile).toBeDefined();
|
||||
const logContent = fs.readFileSync(path.join(daemonDir, logFile!), 'utf8');
|
||||
expect(logContent).toContain(
|
||||
'deferred runtime: cancelled, server closed before startup',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start deferred runtime after close following first health', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-close-after-')),
|
||||
);
|
||||
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
|
||||
enabled: false,
|
||||
sensitiveSpanAttributeMaxLength: 1024 * 1024,
|
||||
});
|
||||
const bridge = makeRuntimeBridge();
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const healthRes = await fetch(`${handle.url}/health`);
|
||||
expect(healthRes.status).toBe(200);
|
||||
expect(await healthRes.json()).toEqual({ status: 'ok' });
|
||||
|
||||
await handle.close();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(createBridge).not.toHaveBeenCalled();
|
||||
await expect(handle.runtimeReady).rejects.toThrow(
|
||||
RUNTIME_STARTUP_CANCELLED_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not cancel deferred runtime once startup is already running', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-close-running-')),
|
||||
);
|
||||
let resolveTelemetry:
|
||||
| ((settings: qwenCore.ResolvedTelemetrySettings) => void)
|
||||
| undefined;
|
||||
const telemetryPromise = new Promise<qwenCore.ResolvedTelemetrySettings>(
|
||||
(resolve) => {
|
||||
resolveTelemetry = resolve;
|
||||
},
|
||||
);
|
||||
const resolveTelemetrySettings = vi
|
||||
.spyOn(qwenCore, 'resolveTelemetrySettings')
|
||||
.mockReturnValue(telemetryPromise);
|
||||
const bridge = makeRuntimeBridge();
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const healthRes = await fetch(`${handle.url}/health`);
|
||||
expect(healthRes.status).toBe(200);
|
||||
expect(await healthRes.json()).toEqual({ status: 'ok' });
|
||||
await vi.waitFor(
|
||||
() => expect(resolveTelemetrySettings).toHaveBeenCalledTimes(1),
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
const closePromise = handle.close();
|
||||
resolveTelemetry?.({
|
||||
enabled: false,
|
||||
sensitiveSpanAttributeMaxLength: 1024 * 1024,
|
||||
});
|
||||
await closePromise;
|
||||
|
||||
expect(createBridge).toHaveBeenCalledTimes(1);
|
||||
await expect(handle.runtimeReady).rejects.toThrow(
|
||||
'Daemon runtime stopped before mounting.',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry deferred runtime after startup failure and later health probe', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-fail-once-')),
|
||||
);
|
||||
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
|
||||
enabled: false,
|
||||
sensitiveSpanAttributeMaxLength: 1024 * 1024,
|
||||
});
|
||||
const createBridge = vi
|
||||
.spyOn(acpBridge, 'createAcpSessionBridge')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('runtime boom');
|
||||
});
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{
|
||||
resolveOnListen: true,
|
||||
deferRuntimeUntilFirstHealth: true,
|
||||
runtimeStartupTimeoutMs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const firstHealthRes = await fetch(`${handle.url}/health`);
|
||||
expect(firstHealthRes.status).toBe(200);
|
||||
expect(await firstHealthRes.json()).toEqual({ status: 'ok' });
|
||||
await expect(handle.runtimeReady).rejects.toThrow('runtime boom');
|
||||
expect(createBridge).toHaveBeenCalledTimes(1);
|
||||
|
||||
const secondHealthRes = await fetch(`${handle.url}/health`);
|
||||
expect(secondHealthRes.status).toBe(503);
|
||||
expect(await secondHealthRes.json()).toMatchObject({
|
||||
status: 'degraded',
|
||||
error: 'runtime boom',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(createBridge).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('flushes runtime startup failures to the daemon log when closing', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-fail-log-')),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import express, {
|
|||
type Application,
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type RequestHandler,
|
||||
type Response,
|
||||
} from 'express';
|
||||
import { writeStderrLine, writeStdoutLine } from '../utils/stdioHelpers.js';
|
||||
|
|
@ -32,6 +33,7 @@ import type {
|
|||
} from '@qwen-code/qwen-code-core';
|
||||
import { createBridgeFileSystemAdapter } from './bridge-file-system-adapter.js';
|
||||
import { isLoopbackBind } from './loopback-binds.js';
|
||||
import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js';
|
||||
import { resolveWebShellDir } from './web-shell-resolver.js';
|
||||
import {
|
||||
allowOriginCors,
|
||||
|
|
@ -94,6 +96,10 @@ const QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS_ENV =
|
|||
'QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS';
|
||||
const SHUTDOWN_FORCE_CLOSE_MS = 5_000;
|
||||
const DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS = 120_000;
|
||||
// Let the first /health response flush before evaluating the runtime graph.
|
||||
const FAST_PATH_RUNTIME_START_AFTER_HEALTH_MS = 50;
|
||||
// Keep manual/non-probed starts moving; health probes cancel this fallback.
|
||||
const FAST_PATH_RUNTIME_START_FALLBACK_MS = 1_000;
|
||||
const RUNTIME_STARTUP_TIMEOUT_ENV = 'QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS';
|
||||
const MAX_EVENT_RING_SIZE = 1_000_000;
|
||||
const DEFAULT_MAX_SESSIONS = 20;
|
||||
|
|
@ -494,6 +500,12 @@ export interface RunQwenServeDeps {
|
|||
* the runtime bridge and routes are mounted.
|
||||
*/
|
||||
resolveOnListen?: boolean;
|
||||
/**
|
||||
* Internal serve fast-path mode: keep bootstrap /health responsive before
|
||||
* starting the heavier runtime graph. A fallback timer still starts runtime
|
||||
* when no health probe arrives. Only applies with resolveOnListen.
|
||||
*/
|
||||
deferRuntimeUntilFirstHealth?: boolean;
|
||||
/**
|
||||
* Bounds background runtime mounting after the listener is ready. Defaults to
|
||||
* QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS, then 120s. Use 0 to disable.
|
||||
|
|
@ -801,6 +813,15 @@ export async function waitForRuntimeStartingForShutdown(
|
|||
});
|
||||
}
|
||||
|
||||
const BOOTSTRAP_HEALTH_PATH = '/health';
|
||||
const BOOTSTRAP_CAPABILITIES_PATH = '/capabilities';
|
||||
const BOOTSTRAP_DAEMON_STATUS_PATH = '/daemon/status';
|
||||
const BOOTSTRAP_SERVE_PATHS = new Set([
|
||||
BOOTSTRAP_HEALTH_PATH,
|
||||
BOOTSTRAP_CAPABILITIES_PATH,
|
||||
BOOTSTRAP_DAEMON_STATUS_PATH,
|
||||
]);
|
||||
|
||||
function createBootstrapServeApp(input: {
|
||||
opts: ServeOptions;
|
||||
getPort: () => number;
|
||||
|
|
@ -811,6 +832,7 @@ function createBootstrapServeApp(input: {
|
|||
sessionShellCommandEnabled: boolean;
|
||||
permissionPolicy: PermissionPolicy | undefined;
|
||||
getRuntimeError: () => string | undefined;
|
||||
onHealthServed?: () => void;
|
||||
}): Application {
|
||||
const {
|
||||
opts,
|
||||
|
|
@ -822,6 +844,7 @@ function createBootstrapServeApp(input: {
|
|||
sessionShellCommandEnabled,
|
||||
permissionPolicy,
|
||||
getRuntimeError,
|
||||
onHealthServed,
|
||||
} = input;
|
||||
const app = express();
|
||||
|
||||
|
|
@ -843,21 +866,24 @@ function createBootstrapServeApp(input: {
|
|||
return;
|
||||
}
|
||||
|
||||
if (onHealthServed) {
|
||||
res.once('finish', onHealthServed);
|
||||
}
|
||||
res.status(200).json({ status: 'ok' });
|
||||
};
|
||||
const loopback = isLoopbackBind(opts.hostname);
|
||||
const exposeHealthPreAuth = loopback && !opts.requireAuth;
|
||||
if (exposeHealthPreAuth) {
|
||||
app.get('/health', healthHandler);
|
||||
app.get(BOOTSTRAP_HEALTH_PATH, healthHandler);
|
||||
}
|
||||
|
||||
app.use(bearerAuth(opts.token));
|
||||
|
||||
if (!exposeHealthPreAuth) {
|
||||
app.get('/health', healthHandler);
|
||||
app.get(BOOTSTRAP_HEALTH_PATH, healthHandler);
|
||||
}
|
||||
|
||||
app.get('/capabilities', (_req: Request, res: Response): void => {
|
||||
app.get(BOOTSTRAP_CAPABILITIES_PATH, (_req: Request, res: Response): void => {
|
||||
res.status(200).json(
|
||||
createBootstrapCapabilities({
|
||||
opts,
|
||||
|
|
@ -869,7 +895,7 @@ function createBootstrapServeApp(input: {
|
|||
);
|
||||
});
|
||||
|
||||
app.get('/daemon/status', (req: Request, res: Response): void => {
|
||||
app.get(BOOTSTRAP_DAEMON_STATUS_PATH, (req: Request, res: Response): void => {
|
||||
const detail = parseDaemonStatusDetail(req.query['detail']);
|
||||
if (!detail.ok || !detail.detail) {
|
||||
res.status(400).json({
|
||||
|
|
@ -1008,20 +1034,92 @@ function createBootstrapServeApp(input: {
|
|||
function createDelegatingServeApp(
|
||||
bootstrapApp: Application,
|
||||
getRuntimeApp: () => Application | undefined,
|
||||
options: {
|
||||
waitForDeferredRuntimeRoutes?: boolean;
|
||||
startRuntime?: () => void;
|
||||
runtimeReady?: Promise<void>;
|
||||
authenticateDeferredRuntimeRequest?: RequestHandler;
|
||||
} = {},
|
||||
): Application {
|
||||
const app = express();
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
const target = getRuntimeApp() ?? bootstrapApp;
|
||||
const handler = target as unknown as (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => void;
|
||||
handler(req, res, next);
|
||||
const dispatch = async (): Promise<void> => {
|
||||
let target = getRuntimeApp();
|
||||
if (
|
||||
!target &&
|
||||
options.waitForDeferredRuntimeRoutes === true &&
|
||||
!isBootstrapServeRoute(req) &&
|
||||
!isCorsPreflightRequest(req) &&
|
||||
options.startRuntime &&
|
||||
options.runtimeReady
|
||||
) {
|
||||
if (
|
||||
options.authenticateDeferredRuntimeRequest &&
|
||||
!runSynchronousRequestGate(
|
||||
options.authenticateDeferredRuntimeRequest,
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
options.startRuntime();
|
||||
try {
|
||||
await options.runtimeReady;
|
||||
} catch {
|
||||
// Fall through to the bootstrap app so it can report the startup error.
|
||||
}
|
||||
target = getRuntimeApp();
|
||||
}
|
||||
const handler = (target ?? bootstrapApp) as unknown as (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => void;
|
||||
handler(req, res, next);
|
||||
};
|
||||
void dispatch().catch(next);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
function isBootstrapServeRoute(req: Request): boolean {
|
||||
const path =
|
||||
req.path.length > 1 && req.path.endsWith('/')
|
||||
? req.path.slice(0, -1)
|
||||
: req.path;
|
||||
return BOOTSTRAP_SERVE_PATHS.has(path);
|
||||
}
|
||||
|
||||
function isCorsPreflightRequest(req: Request): boolean {
|
||||
return (
|
||||
req.method === 'OPTIONS' &&
|
||||
Boolean(req.headers.origin) &&
|
||||
Boolean(
|
||||
req.headers['access-control-request-method'] ||
|
||||
req.headers['access-control-request-headers'],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function runSynchronousRequestGate(
|
||||
handler: RequestHandler,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): boolean {
|
||||
let passed = false;
|
||||
handler(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
passed = true;
|
||||
});
|
||||
return passed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate options + start the listener. Resolves once the server is ready
|
||||
* to accept connections.
|
||||
|
|
@ -1503,6 +1601,10 @@ export async function runQwenServe(
|
|||
let markRuntimeReady!: () => void;
|
||||
let markRuntimeFailed!: (err: Error) => void;
|
||||
let runtimeStartupSettled = false;
|
||||
let startRuntimeAfterHealth: (() => void) | undefined;
|
||||
let startRuntimeForRequest: (() => void) | undefined;
|
||||
const deferRuntimeUntilFirstHealth =
|
||||
deps.resolveOnListen === true && deps.deferRuntimeUntilFirstHealth === true;
|
||||
const runtimeReady = new Promise<void>((resolve, reject) => {
|
||||
markRuntimeReady = resolve;
|
||||
markRuntimeFailed = reject;
|
||||
|
|
@ -1890,9 +1992,18 @@ export async function runQwenServe(
|
|||
sessionShellCommandEnabled,
|
||||
permissionPolicy,
|
||||
getRuntimeError: () => runtimeStartupError,
|
||||
onHealthServed: deferRuntimeUntilFirstHealth
|
||||
? () => startRuntimeAfterHealth?.()
|
||||
: undefined,
|
||||
});
|
||||
const app =
|
||||
runtimeApp ?? createDelegatingServeApp(bootstrapApp, () => runtimeApp);
|
||||
runtimeApp ??
|
||||
createDelegatingServeApp(bootstrapApp, () => runtimeApp, {
|
||||
waitForDeferredRuntimeRoutes: deferRuntimeUntilFirstHealth,
|
||||
startRuntime: () => startRuntimeForRequest?.(),
|
||||
runtimeReady,
|
||||
authenticateDeferredRuntimeRequest: bearerAuth(opts.token),
|
||||
});
|
||||
|
||||
// Node's `app.listen()` wants the unbracketed IPv6 literal (`::1`) but
|
||||
// operators conventionally type `[::1]` (or copy/paste from URLs that
|
||||
|
|
@ -2023,6 +2134,8 @@ export async function runQwenServe(
|
|||
let shuttingDown = false;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
let runtimeStartupTimer: NodeJS.Timeout | undefined;
|
||||
let runtimeStartAfterHealthTimer: NodeJS.Timeout | undefined;
|
||||
let runtimeStartFallbackTimer: NodeJS.Timeout | undefined;
|
||||
const runtimeStartupTimeoutMs = resolveRuntimeStartupTimeoutMs(
|
||||
deps.runtimeStartupTimeoutMs,
|
||||
);
|
||||
|
|
@ -2031,6 +2144,31 @@ export async function runQwenServe(
|
|||
clearTimeout(runtimeStartupTimer);
|
||||
runtimeStartupTimer = undefined;
|
||||
};
|
||||
const clearRuntimeStartFallbackTimer = (): void => {
|
||||
if (!runtimeStartFallbackTimer) return;
|
||||
clearTimeout(runtimeStartFallbackTimer);
|
||||
runtimeStartFallbackTimer = undefined;
|
||||
};
|
||||
const clearRuntimeStartAfterHealthTimer = (): void => {
|
||||
if (!runtimeStartAfterHealthTimer) return;
|
||||
clearTimeout(runtimeStartAfterHealthTimer);
|
||||
runtimeStartAfterHealthTimer = undefined;
|
||||
};
|
||||
const cancelDeferredRuntimeStartup = (): void => {
|
||||
if (
|
||||
!deferRuntimeUntilFirstHealth ||
|
||||
runtimeStarting ||
|
||||
runtimeStartupSettled
|
||||
)
|
||||
return;
|
||||
daemonLog.info(
|
||||
'deferred runtime: cancelled, server closed before startup',
|
||||
);
|
||||
runtimeStartupSettled = true;
|
||||
const error = new Error(RUNTIME_STARTUP_CANCELLED_MESSAGE);
|
||||
runtimeStartupError = error.message;
|
||||
markRuntimeFailed(error);
|
||||
};
|
||||
const shutdownBridgeAfterFailedStartup = async (
|
||||
bridge: AcpSessionBridge | undefined,
|
||||
): Promise<void> => {
|
||||
|
|
@ -2098,6 +2236,8 @@ export async function runQwenServe(
|
|||
};
|
||||
const startRuntime = (): void => {
|
||||
if (runtimeStarting) return;
|
||||
clearRuntimeStartAfterHealthTimer();
|
||||
clearRuntimeStartFallbackTimer();
|
||||
runtimeStarting = buildRuntime()
|
||||
.then(async (runtime) => {
|
||||
if (runtimeStartupSettled) {
|
||||
|
|
@ -2137,6 +2277,37 @@ export async function runQwenServe(
|
|||
runtimeStartupTimer.unref();
|
||||
}
|
||||
};
|
||||
startRuntimeForRequest = startRuntime;
|
||||
const scheduleRuntimeStartFallback = (): void => {
|
||||
if (shuttingDown || runtimeStarting || runtimeStartFallbackTimer)
|
||||
return;
|
||||
daemonLog.info(
|
||||
`deferred runtime: scheduling fallback start in ${FAST_PATH_RUNTIME_START_FALLBACK_MS}ms`,
|
||||
);
|
||||
runtimeStartFallbackTimer = setTimeout(() => {
|
||||
runtimeStartFallbackTimer = undefined;
|
||||
if (shuttingDown) return;
|
||||
daemonLog.info('deferred runtime: fallback timer fired, starting');
|
||||
startRuntime();
|
||||
}, FAST_PATH_RUNTIME_START_FALLBACK_MS);
|
||||
runtimeStartFallbackTimer.unref();
|
||||
};
|
||||
startRuntimeAfterHealth = (): void => {
|
||||
if (shuttingDown || runtimeStarting || runtimeStartAfterHealthTimer) {
|
||||
return;
|
||||
}
|
||||
clearRuntimeStartFallbackTimer();
|
||||
daemonLog.info(
|
||||
`deferred runtime: health served, scheduling start in ${FAST_PATH_RUNTIME_START_AFTER_HEALTH_MS}ms`,
|
||||
);
|
||||
runtimeStartAfterHealthTimer = setTimeout(() => {
|
||||
runtimeStartAfterHealthTimer = undefined;
|
||||
if (shuttingDown) return;
|
||||
daemonLog.info('deferred runtime: health timer fired, starting');
|
||||
startRuntime();
|
||||
}, FAST_PATH_RUNTIME_START_AFTER_HEALTH_MS);
|
||||
runtimeStartAfterHealthTimer.unref();
|
||||
};
|
||||
|
||||
// Forward declaration so handle.close can detach the listener after
|
||||
// drain completes. The handler is registered just before `resolve()`.
|
||||
|
|
@ -2194,6 +2365,9 @@ export async function runQwenServe(
|
|||
if (closePromise) return closePromise;
|
||||
closePromise = new Promise<void>((res, rej) => {
|
||||
shuttingDown = true;
|
||||
clearRuntimeStartAfterHealthTimer();
|
||||
clearRuntimeStartFallbackTimer();
|
||||
cancelDeferredRuntimeStartup();
|
||||
// NOTE: the SIGINT/SIGTERM handlers stay attached during the
|
||||
// drain. Their `if (shuttingDown) return` guard makes a second
|
||||
// signal a no-op. Detaching them up front would leave Node's
|
||||
|
|
@ -2402,6 +2576,8 @@ export async function runQwenServe(
|
|||
if (shouldPreheat) {
|
||||
startBridgePreheat(bridgeRef);
|
||||
}
|
||||
} else if (deferRuntimeUntilFirstHealth) {
|
||||
scheduleRuntimeStartFallback();
|
||||
} else {
|
||||
startRuntime();
|
||||
}
|
||||
|
|
|
|||
8
packages/cli/src/serve/runtime-startup-errors.ts
Normal file
8
packages/cli/src/serve/runtime-startup-errors.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const RUNTIME_STARTUP_CANCELLED_MESSAGE =
|
||||
'Daemon runtime cancelled: server closed before startup.';
|
||||
|
|
@ -5,21 +5,16 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ApprovalMode, type Config } from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
HEADLESS_YOLO_NO_SANDBOX_WARNING,
|
||||
getHeadlessYoloSafetyWarning,
|
||||
} from './headlessSafetyWarnings.js';
|
||||
|
||||
function makeConfig(
|
||||
approvalMode: ApprovalMode,
|
||||
sandbox: unknown,
|
||||
): Pick<Config, 'getApprovalMode' | 'getSandbox'> {
|
||||
function makeConfig(approvalMode: ApprovalMode, sandbox: unknown) {
|
||||
return {
|
||||
getApprovalMode: () => approvalMode,
|
||||
// Real return type is `SandboxConfig | undefined`; the warning policy
|
||||
// only cares about truthiness so the tests model it as such.
|
||||
getSandbox: () => sandbox as ReturnType<Config['getSandbox']>,
|
||||
getSandbox: () => sandbox,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ApprovalMode, type Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const HEADLESS_YOLO_NO_SANDBOX_WARNING =
|
||||
'Warning: running headless with --yolo / approval-mode=yolo and no sandbox. ' +
|
||||
"All tool calls (shell, write, edit) auto-execute at this process's privilege level. " +
|
||||
|
|
@ -26,10 +24,14 @@ export const HEADLESS_YOLO_NO_SANDBOX_WARNING =
|
|||
* fall through to `process.env`.
|
||||
*/
|
||||
export function getHeadlessYoloSafetyWarning(
|
||||
config: Pick<Config, 'getApprovalMode' | 'getSandbox'>,
|
||||
config: {
|
||||
getApprovalMode(): string | undefined;
|
||||
getSandbox(): unknown;
|
||||
},
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | null {
|
||||
if (config.getApprovalMode() !== ApprovalMode.YOLO) return null;
|
||||
// Keep this literal in sync with ApprovalMode.YOLO without importing core at runtime.
|
||||
if (config.getApprovalMode() !== 'yolo') return null;
|
||||
if (config.getSandbox()) return null;
|
||||
// `SANDBOX` is set by the sandbox transport itself: macOS seatbelt sets
|
||||
// it to `sandbox-exec`, Docker/Podman to the container name (e.g.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue