diff --git a/packages/cli/src/serve/fast-path-open.test.ts b/packages/cli/src/serve/fast-path-open.test.ts new file mode 100644 index 0000000000..06c4c759aa --- /dev/null +++ b/packages/cli/src/serve/fast-path-open.test.ts @@ -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((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((_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); + }); +}); diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index fe5df12cec..2076f70d66 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -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 + >); + 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'), '{'); diff --git a/packages/cli/src/serve/fast-path.ts b/packages/cli/src/serve/fast-path.ts index 96a35ef27c..b458f38b0d 100644 --- a/packages/cli/src/serve/fast-path.ts +++ b/packages/cli/src/serve/fast-path.ts @@ -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; @@ -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 { 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( diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 6312100f7e..6f3434f3fa 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -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>, ); @@ -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, ); @@ -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, + ); + + 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, + ); + + 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, + ); + 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, + ); + 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, + ); + + 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, + ); + + 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( + (resolve) => { + resolveTelemetry = resolve; + }, + ); + vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockReturnValue( + telemetryPromise, + ); + const bridge = makeRuntimeBridge(); + const createBridge = vi + .spyOn(acpBridge, 'createAcpSessionBridge') + .mockReturnValue( + bridge as ReturnType, + ); + + 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((_, 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, + ); + + 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, + ); + + 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, + ); + + 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( + (resolve) => { + resolveTelemetry = resolve; + }, + ); + const resolveTelemetrySettings = vi + .spyOn(qwenCore, 'resolveTelemetrySettings') + .mockReturnValue(telemetryPromise); + const bridge = makeRuntimeBridge(); + const createBridge = vi + .spyOn(acpBridge, 'createAcpSessionBridge') + .mockReturnValue( + bridge as ReturnType, + ); + + 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-')), diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index efef1d9bca..809568b3cf 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -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; + 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 => { + 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((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 | 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 => { @@ -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((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(); } diff --git a/packages/cli/src/serve/runtime-startup-errors.ts b/packages/cli/src/serve/runtime-startup-errors.ts new file mode 100644 index 0000000000..25f093bd17 --- /dev/null +++ b/packages/cli/src/serve/runtime-startup-errors.ts @@ -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.'; diff --git a/packages/cli/src/utils/headlessSafetyWarnings.test.ts b/packages/cli/src/utils/headlessSafetyWarnings.test.ts index 35a573335b..a8e625a3d7 100644 --- a/packages/cli/src/utils/headlessSafetyWarnings.test.ts +++ b/packages/cli/src/utils/headlessSafetyWarnings.test.ts @@ -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 { +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, + getSandbox: () => sandbox, }; } diff --git a/packages/cli/src/utils/headlessSafetyWarnings.ts b/packages/cli/src/utils/headlessSafetyWarnings.ts index 34f931b4b6..587521bce1 100644 --- a/packages/cli/src/utils/headlessSafetyWarnings.ts +++ b/packages/cli/src/utils/headlessSafetyWarnings.ts @@ -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(): 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.