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:
jinye 2026-06-30 18:39:08 +08:00 committed by GitHub
parent e1ddcdc2a7
commit f37c64c7f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1190 additions and 55 deletions

View 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);
});
});

View file

@ -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'), '{');

View file

@ -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(

View file

@ -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-')),

View file

@ -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();
}

View 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.';

View file

@ -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,
};
}

View file

@ -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.