From 9c472cfbccda8007bc8e816cf6408d529b90e64d Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Tue, 30 Jun 2026 20:58:31 +0800 Subject: [PATCH 01/11] perf(cli): defer startup prefetch tasks --- .../fire-and-forget-startup-prefetch.md | 349 ++++++++++++++++++ packages/cli/src/core/initializer.test.ts | 26 +- packages/cli/src/core/initializer.ts | 36 +- packages/cli/src/gemini.test.tsx | 62 +++- packages/cli/src/gemini.tsx | 42 +-- .../cli/src/startup/startup-prefetch.test.ts | 188 ++++++++++ packages/cli/src/startup/startup-prefetch.ts | 102 +++++ packages/cli/src/ui/startInteractiveUI.tsx | 17 +- 8 files changed, 746 insertions(+), 76 deletions(-) create mode 100644 docs/design/fire-and-forget-startup-prefetch.md create mode 100644 packages/cli/src/startup/startup-prefetch.test.ts create mode 100644 packages/cli/src/startup/startup-prefetch.ts diff --git a/docs/design/fire-and-forget-startup-prefetch.md b/docs/design/fire-and-forget-startup-prefetch.md new file mode 100644 index 00000000000..594bb4cf695 --- /dev/null +++ b/docs/design/fire-and-forget-startup-prefetch.md @@ -0,0 +1,349 @@ +# Fire-and-Forget Startup Prefetch 启动优化设计 + +## 背景与目标 + +父 issue #3011 将 qwen-code 启动优化拆成多项子任务。当前仓库已经落地了部分基础能力: + +- #3219:启动性能 profiler 已接入,支持 `QWEN_CODE_PROFILE_STARTUP=1` 输出启动阶段 JSON。 +- #3221:工具注册已改为 lazy factory,`Config.initialize()` 不再静态实例化所有工具。 +- #3223:API preconnect 已存在,当前在 `loadCliConfig()` 后以 fire-and-forget 方式触发。 +- early input capture、MCP 渐进发现、AppContainer 渲染后 `config.initialize()` 也已部分实现。 + +#3222 的目标不是重做这些能力,而是把仍散落在启动路径中的非关键启动操作统一收敛成 fire-and-forget prefetch:首屏前只等待确实影响正确性的操作,首屏后启动不影响首次交互正确性的后台任务,同时保持非交互模式的兼容语义。 + +## 现有启动流程 + +当前交互式启动路径的关键流程如下: + +```mermaid +flowchart TD + A[packages/cli/index.ts] --> B[initStartupProfiler] + B --> C[gemini.main] + C --> D[parseArguments] + D --> E[loadSettings] + E --> F[sandbox / worktree / relaunch checks] + F --> G[loadCliConfig] + G --> H[register cleanup + preconnectApi fire-and-forget] + H --> I[early input capture + kitty/theme probes] + I --> J[initializeApp awaited] + J --> K{interactive?} + K -->|yes| L[startInteractiveUI] + L --> M[Ink render returns / first_paint] + M --> N[checkForUpdates fire-and-forget] + M --> O[AppContainer useEffect] + O --> P[config.initialize awaited after render] + P --> Q[MCP discovery background] + P --> R[input_enabled] + K -->|no| S[config.initialize] + S --> T[waitForMcpReady] + T --> U[runNonInteractive] +``` + +现状判断: + +- `initializeApp()` 仍在首屏前串行执行 i18n、auth、theme validation、IDE client connection。 +- auth 和 i18n 必须留在首屏前;IDE connection 对普通 TUI 的首屏和输入可用性不是硬依赖,可以在普通 TUI 路径延后;但对 `qwen -p`、stream-json、ACP/Zed 这类没有 TUI post-render 挂点或首个请求需要 IDE context/status 的路径,必须继续在首个请求前 await。 +- `checkForUpdates()` 已在 `startInteractiveUI()` 的 render 后 fire-and-forget,但逻辑散落在 UI 启动函数里。 +- `preconnectApi()` 已是 fire-and-forget,应该保留尽早触发,但纳入统一调度。 +- interactive 路径中,`config.initialize()` 已在 React mount 后执行;MCP discovery 已在 core 内部后台运行,并由 AppContainer 批量刷新工具列表。 +- non-interactive 路径仍需要等待 `config.waitForMcpReady()`,否则第一个 prompt 可能看不到 MCP 工具,造成脚本行为回退。 + +## 目标架构 + +新增一个很小的启动预取调度层,统一管理“启动但不等待”的任务,按触发时机分为 early 和 post-render 两类。 + +```mermaid +flowchart LR + subgraph CLI[CLI startup] + G[loadCliConfig] --> EP[startEarlyStartupPrefetches] + EP --> PC[API preconnect] + EP --> HK[background housekeeping import] + G --> IA[initializeAppCritical] + G --> HI[initializeAppWithAwaitedIde for headless / stream-json / ACP] + IA --> UI[startInteractiveUI] + end + + subgraph Prefetch[StartupPrefetchController] + SP[startPostRenderPrefetches] + SP --> UP[update check] + SP --> IDE[IDE client connect only for ordinary TUI] + SP --> PROF[profile async task events] + end + + subgraph UI[Interactive UI] + UI --> FP[Ink render / first_paint] + FP --> SP + FP --> AC[AppContainer] + AC --> CI[config.initialize] + CI --> MCP[MCP discovery background] + MCP --> BT[batched setTools] + end + + subgraph Headless[Non-interactive] + CI2[config.initialize] --> WM[waitForMcpReady] + WM --> RUN[runNonInteractive] + end +``` + +新方案的交互式启动时序: + +```mermaid +sequenceDiagram + participant Main as gemini.main() + participant Prefetch as StartupPrefetchController + participant UI as startInteractiveUI() + participant App as AppContainer + participant MCP as McpClientManager + + Main->>Main: parseArguments + loadSettings + Main->>Main: loadCliConfig + Main->>Prefetch: startEarlyStartupPrefetches(config) + Prefetch-->>Prefetch: void preconnectApi() + Main->>Main: await initializeAppCritical(deferIdeConnection=true for ordinary TUI) + Main->>UI: startInteractiveUI(...) + UI->>UI: render() + UI->>Prefetch: startPostRenderPrefetches(config, settings) + Prefetch-->>Prefetch: void checkForUpdates() + Prefetch-->>Prefetch: void connectIdeClient() for ordinary TUI only + App->>App: await config.initialize() + App->>MCP: start background discovery + App->>App: input_enabled + MCP-->>App: mcp-client-update batches + App-->>MCP: geminiClient.setTools() +``` + +## 设计改动 + +### 1. 新增统一启动预取调度器 + +新增 `packages/cli/src/startup/startup-prefetch.ts`,提供两个入口: + +```ts +startEarlyStartupPrefetches(config: Config): void; +startPostRenderPrefetches( + config: Config, + settings: LoadedSettings, + options?: { connectIde?: boolean }, +): void; +``` + +调度器只做三件事: + +- 按名称启动预取任务。 +- 用 `void task().catch(...)` 明确不等待、不抛出。 +- 记录 debug 日志和 profiler async event,便于验证任务是否在 render 前后启动。 + +调度器需要保证每个阶段幂等,避免 React StrictMode、测试重复调用或异常重入导致同一任务启动多次。 + +### 2. early prefetch:保留最大提前量 + +`startEarlyStartupPrefetches(config)` 在 `loadCliConfig()` 成功后立即调用。 + +第一阶段只纳入 API preconnect: + +- 从 `config.getModelsConfig()` 读取当前 auth type 和 resolved base URL。 +- 从 `config.getProxy()` 读取 proxy。 +- 调用现有 `preconnectApi(authType, { resolvedBaseUrl, proxy })`。 +- 保留现有环境门控:`QWEN_CODE_DISABLE_PRECONNECT`、sandbox、custom CA、非 Node runtime、无 proxy 等。 + +这部分不新增设置项。preconnect 失败只写 debug 日志,不影响启动。 + +### 3. post-render prefetch:首屏后启动 + +`startPostRenderPrefetches(config, settings)` 在 `startInteractiveUI()` 中 Ink `render()` 返回并记录 `first_paint` 后调用。 + +首批纳入: + +- update check:迁移现有 `checkForUpdates().then(handleAutoUpdate)` 逻辑,保留 `settings.merged.general?.enableAutoUpdate !== false` 的门控。 +- IDE client connection:只在普通 interactive TUI 路径中移到 post-render prefetch。调用方必须显式传入 `connectIde: true`,并且调度器内部仍需检查 `config.getIdeMode()`。非交互、stream-json、ACP/Zed 不通过这个入口延后 IDE 连接。 +- background housekeeping:可从 `gemini.tsx` 迁移到 post-render prefetch,使所有后台启动任务有统一入口;仍限定 interactive,仍保持 dynamic import 和错误吞噬。 + +这些任务都不能影响 `startInteractiveUI()` 的返回值,也不能向 TUI stderr 写用户可见错误。失败只进入 debug log。 + +### 4. 拆分 `initializeApp()` 的关键路径,并保留非 TUI awaited IDE 连接 + +新增一个共享 helper,避免 TUI deferred 路径和非 TUI awaited 路径复制 IDE 连接逻辑: + +```ts +export async function connectIdeForStartup(config: Config): Promise { + if (!config.getIdeMode()) return; + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); +} +``` + +`initializeApp()` 保留为首屏前关键初始化,但增加一个显式选项: + +```ts +interface InitializeAppOptions { + deferIdeConnection?: boolean; +} +``` + +默认值必须保持兼容:`deferIdeConnection` 默认为 `false`。也就是说,不传选项时仍会在 `initializeApp()` 内 await IDE 连接。 + +`initializeApp()` 的 awaited 内容变为: + +- `initializeI18n(...)` +- `performInitialAuth(...)` +- `validateTheme(settings)` +- 当 `deferIdeConnection !== true` 时,`await connectIdeForStartup(config)` +- 计算 `shouldOpenAuthDialog` +- 读取 `config.getGeminiMdFileCount()` + +`gemini.tsx` 调用处负责按运行模式选择: + +```ts +const deferIdeConnection = + config.isInteractive() && !config.getExperimentalZedIntegration(); + +const initializationResult = await initializeApp(config, settings, { + deferIdeConnection, +}); +``` + +随后只有当 `deferIdeConnection === true` 时,`startInteractiveUI()` 才通过 `startPostRenderPrefetches(..., { connectIde: true })` fire-and-forget 启动 IDE 连接。 + +这个分流修正 review 指出的兼容性风险: + +- 普通 interactive TUI:IDE socket/IPC 连接不再阻塞首屏。 +- `qwen -p` / piped stdin:继续在首个模型请求前 await IDE 连接。 +- stream-json:继续在 session/control 请求处理前完成 IDE 连接。 +- ACP/Zed:继续保留 awaited IDE startup,避免首个请求缺 IDE context/status。 + +### 5. MCP 与非交互语义保持不变 + +本方案不改 core MCP 状态机。 + +interactive: + +- 继续由 `AppContainer` 在 mount effect 中调用 `config.initialize()`。 +- `Config.initialize()` 继续启动 background MCP discovery。 +- AppContainer 继续监听 `mcp-client-update` 并以约 16ms 批量调用 `geminiClient.setTools()`。 +- 首屏和输入可用不等待 MCP 全部 settle。 + +non-interactive / stream-json / ACP: + +- 继续在首个模型请求前 await IDE 连接。 +- 继续在首个模型请求前等待 `config.waitForMcpReady()`。 +- 保持旧同步路径的工具可见性语义。 +- 保持 MCP 失败时 stderr warning 的现有行为。 + +## 性能收益预估 + +收益分为两类。 + +第一类是首屏前关键路径缩短: + +- 普通 interactive TUI 的 IDE client connection 不再阻塞首屏;收益取决于 IDE socket/IPC 连接耗时,预期为几十毫秒到数百毫秒。 +- update check、housekeeping、preconnect 等任务有统一 fire-and-forget 入口,不会被未来维护误放回 awaited path。 + +第二类是首个 API 请求收益: + +- 继续保留 #3223 的 API preconnect 设计。 +- 当 proxy/shared dispatcher 可复用时,首个 API 请求可减少 TCP+TLS 握手成本,预期 100-200ms。 + +需要注意:#3219 的历史 baseline 显示模块加载曾占启动总耗时约 94%,#3221 lazy tool registration 已经针对最大瓶颈做过优化。#3222 的核心收益更偏 perceived TTI 和首屏响应,而不是消除全部模块加载成本。 + +## 风险与影响范围 + +### 风险 + +- 普通 TUI 的 IDE 能力可能从“首屏前已连接”变为“首屏后很快连接”。缓解方式:只在普通 interactive TUI 路径延后;非交互、stream-json、ACP/Zed 保持首个请求前 awaited 连接。 +- deferred task 失败可能不明显。缓解方式:统一 wrapper 记录 debug log 和 profiler async event。 +- 迁移 update/preconnect 时可能改变原有门控。缓解方式:逐字保留现有 settings/env 条件。 +- 过度 defer 可能导致首个用户输入依赖的能力未就绪。缓解方式:auth、config construction、permissions、hooks、memory、tool registry、non-interactive MCP ready 全部保持 awaited。 + +### 影响范围 + +预计只涉及 CLI 启动层: + +- `packages/cli/src/startup/startup-prefetch.ts` +- `packages/cli/src/core/initializer.ts` +- `packages/cli/src/gemini.tsx` +- `packages/cli/src/ui/startInteractiveUI.tsx` +- 对应单元测试 + +不改动: + +- CLI 参数与配置 schema +- core tool registry 协议 +- MCP discovery 状态机 +- 模型请求协议 +- 用户可见命令行为 + +## 单元测试计划 + +### `packages/cli/src/startup/startup-prefetch.test.ts` + +覆盖: + +- `startEarlyStartupPrefetches()` 会调用 `preconnectApi()`,并传入 auth type、resolved base URL、proxy。 +- early prefetch 不 await 任务完成。 +- repeated call 幂等,不重复启动同一 early task。 +- `startPostRenderPrefetches()` 在 `enableAutoUpdate !== false` 时启动 update check。 +- `enableAutoUpdate === false` 时不启动 update check。 +- `options.connectIde === true` 且 `config.getIdeMode() === true` 时启动 IDE connect 并调用 `logIdeConnection()`。 +- `options.connectIde !== true` 时不触发 IDE connect。 +- `config.getIdeMode() === false` 时即使 `options.connectIde === true` 也不触发 IDE connect。 +- deferred task reject 不会让 public API throw,只写 debug log。 + +### `packages/cli/src/core/initializer.test.ts` + +调整并新增: + +- `initializeApp()` 默认会 await `connectIdeForStartup()`,保持非 TUI 路径兼容。 +- `initializeApp(..., { deferIdeConnection: true })` 不调用 `IdeClient.getInstance()` 或 `connect()`。 +- `initializeApp(..., { deferIdeConnection: false })` 在 `config.getIdeMode() === true` 时调用并 await IDE connect。 +- 仍会 await `initializeI18n()`。 +- 仍会 await `performInitialAuth()`。 +- auth 失败时保留 `authError` 和 `shouldOpenAuthDialog === true`。 +- theme 校验失败时保留 `themeError`。 +- auth type 显式提供且 auth 成功时 `shouldOpenAuthDialog === false`。 + +### `packages/cli/src/ui/startInteractiveUI.test.tsx` + +覆盖: + +- Ink `render()` 返回并记录 `first_paint` 后,调用 `startPostRenderPrefetches(config, settings)`。 +- 普通 TUI 路径传入 `{ connectIde: true }`;非 TUI 路径不通过 `startInteractiveUI()` 触发 IDE prefetch。 +- post-render prefetch reject 不会导致 `startInteractiveUI()` reject。 +- update check 从 `startInteractiveUI()` 内联逻辑移走后,不再被直接调用。 + +### `packages/cli/src/gemini.test.tsx` + +调整并新增: + +- 普通 interactive TUI 调用 `initializeApp(config, settings, { deferIdeConnection: true })`,并在 post-render prefetch 中连接 IDE。 +- `qwen -p` / piped stdin / stream-json 调用 `initializeApp(config, settings, { deferIdeConnection: false })` 或使用默认值,确保首个请求前 IDE 已连接。 +- ACP/Zed 路径不启用 IDE deferred prefetch,继续走 awaited IDE startup。 + +### 回归测试 + +建议执行: + +```bash +cd packages/cli && npx vitest run src/core/initializer.test.ts src/startup/startup-prefetch.test.ts +cd packages/cli && npx vitest run src/gemini.test.tsx +cd packages/core && npx vitest run src/config/config.test.ts src/tools/tool-registry.test.ts +``` + +## 验收标准 + +- interactive REPL 首屏不等待 IDE connection、update check、housekeeping。 +- non-interactive、stream-json、ACP/Zed 在首个请求前仍 await IDE connection。 +- API preconnect 仍在 `loadCliConfig()` 后尽早 fire-and-forget。 +- auth、config、permissions、hooks、memory 等关键正确性初始化仍在需要的位置 await。 +- non-interactive 首个 prompt 仍等待 MCP ready。 +- 所有 deferred task 失败都不影响 REPL 渲染。 +- profiler 能看出 deferred task 在 first_paint 前后按预期启动。 +- 单元测试覆盖关键路径、幂等、错误吞噬和非交互兼容约束。 + +## 默认假设 + +- #3221 在 GitHub 上实际是 issue,不是 PR;当前仓库已经包含 lazy tool registry 实现。 +- 本方案不新增配置项,避免把启动优化变成用户可配置复杂度。 +- “REPL renders before deferred operations complete” 指 Ink 首屏返回和输入可用,不要求所有后台能力在用户看到 UI 前完成。 +- 非交互模式优先兼容性,不追求和 interactive 一样激进的首屏优化。 diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index acfa0240b62..0ac9e5c5110 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -5,7 +5,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { initializeApp } from './initializer.js'; +import { connectIdeForStartup, initializeApp } from './initializer.js'; const mockPerformInitialAuth = vi.fn(); const mockValidateTheme = vi.fn(); @@ -164,7 +164,7 @@ describe('initializeApp', () => { expect(result.shouldOpenAuthDialog).toBe(false); }); - it('should connect to IDE when in IDE mode', async () => { + it('should connect to IDE by default when in IDE mode', async () => { mockConfig.getIdeMode.mockReturnValue(true); await initializeApp(mockConfig as never, mockSettings as never); @@ -174,6 +174,28 @@ describe('initializeApp', () => { expect(mockLogIdeConnection).toHaveBeenCalled(); }); + it('should not connect to IDE when deferred', async () => { + mockConfig.getIdeMode.mockReturnValue(true); + + await initializeApp(mockConfig as never, mockSettings as never, { + deferIdeConnection: true, + }); + + expect(mockGetInstance).not.toHaveBeenCalled(); + expect(mockConnect).not.toHaveBeenCalled(); + expect(mockLogIdeConnection).not.toHaveBeenCalled(); + }); + + it('should connect to IDE through startup helper', async () => { + mockConfig.getIdeMode.mockReturnValue(true); + + await connectIdeForStartup(mockConfig as never); + + expect(mockGetInstance).toHaveBeenCalled(); + expect(mockConnect).toHaveBeenCalled(); + expect(mockLogIdeConnection).toHaveBeenCalled(); + }); + it('should not connect to IDE when not in IDE mode', async () => { mockConfig.getIdeMode.mockReturnValue(false); diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 3b976f33794..0e256b2aa79 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -14,10 +14,7 @@ import { import { type LoadedSettings } from '../config/settings.js'; import { performInitialAuth } from './auth.js'; import { validateTheme } from './theme.js'; -import { - initializeI18n, - resolveLanguageSetting, -} from '../i18n/index.js'; +import { initializeI18n, resolveLanguageSetting } from '../i18n/index.js'; export interface InitializationResult { authError: string | null; @@ -26,6 +23,30 @@ export interface InitializationResult { geminiMdFileCount: number; } +export interface InitializeAppOptions { + /** + * When true, skip the awaited IDE connection inside initializeApp(). + * Ordinary interactive TUI startup uses this so IDE IPC can run after first + * paint; non-TUI paths leave it false so the first request keeps IDE context. + */ + deferIdeConnection?: boolean; +} + +/** + * Establishes the startup IDE connection and records the connection telemetry. + * + * Callers choose whether to await this on the startup critical path. Headless, + * stream-json, and ACP/Zed await it before their first request; ordinary TUI + * startup schedules it post-render through startup prefetch. + */ +export async function connectIdeForStartup(config: Config): Promise { + if (!config.getIdeMode()) return; + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); +} + /** * Orchestrates the application's startup initialization. * This runs BEFORE the React UI is rendered. @@ -36,6 +57,7 @@ export interface InitializationResult { export async function initializeApp( config: Config, settings: LoadedSettings, + options: InitializeAppOptions = {}, ): Promise { // Initialize i18n system await initializeI18n( @@ -52,10 +74,8 @@ export async function initializeApp( const shouldOpenAuthDialog = !config.getModelsConfig().wasAuthTypeExplicitlyProvided() || !!authError; - if (config.getIdeMode()) { - const ideClient = await IdeClient.getInstance(); - await ideClient.connect(); - logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); + if (!options.deferIdeConnection) { + await connectIdeForStartup(config); } return { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index e35b9b85bdc..0002e69fcec 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -29,6 +29,8 @@ import { ApprovalMode, OutputFormat } from '@qwen-code/qwen-code-core'; const mockWriteStderrLine = vi.hoisted(() => vi.fn()); const mockHandleListExtensions = vi.hoisted(() => vi.fn()); +const mockStartEarlyStartupPrefetches = vi.hoisted(() => vi.fn()); +const mockStartPostRenderPrefetches = vi.hoisted(() => vi.fn()); describe('gemini import boundary', () => { it('does not statically import ACP or noninteractive auth branches', () => { @@ -139,6 +141,13 @@ vi.mock('./core/initializer.js', () => ({ }), })); +vi.mock('./startup/startup-prefetch.js', () => ({ + startEarlyStartupPrefetches: (...args: unknown[]) => + mockStartEarlyStartupPrefetches(...args), + startPostRenderPrefetches: (...args: unknown[]) => + mockStartPostRenderPrefetches(...args), +})); + vi.mock('./commands/extensions/list.js', () => ({ handleList: mockHandleListExtensions, })); @@ -534,6 +543,11 @@ describe('gemini.tsx main function', () => { } expect(mockWriteStderrLine).toHaveBeenCalledWith('late memory warning'); + expect(initializerModule.initializeApp).toHaveBeenCalledWith( + configStub, + expect.any(Object), + { deferIdeConnection: false }, + ); }); it('creates non-interactive prompt ids that preserve session correlation', () => { @@ -858,6 +872,11 @@ describe('gemini.tsx main function', () => { configStub, expect.any(Object), ); + expect(initializerModule.initializeApp).toHaveBeenCalledWith( + configStub, + expect.any(Object), + { deferIdeConnection: false }, + ); expect(runExitCleanupMock).toHaveBeenCalledTimes(1); }); }); @@ -927,6 +946,15 @@ describe('gemini.tsx main function kitty protocol', () => { './config/config.js' ); const { loadSettings } = await import('./config/settings.js'); + const initializerModule = await import('./core/initializer.js'); + const initializeAppSpy = vi + .spyOn(initializerModule, 'initializeApp') + .mockResolvedValue({ + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }); vi.mocked(loadCliConfig).mockResolvedValue({ isInteractive: () => true, getQuestion: () => '', @@ -1016,6 +1044,13 @@ describe('gemini.tsx main function kitty protocol', () => { expect(setRawModeSpy).toHaveBeenCalledWith(true); expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1); + expect(initializeAppSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + { + deferIdeConnection: true, + }, + ); }); it('should run cleanup before exiting on interactive SIGINT', async () => { @@ -1248,10 +1283,6 @@ describe('startInteractiveUI', () => { disableKittyProtocol: vi.fn(), })); - vi.mock('./ui/utils/updateCheck.js', () => ({ - checkForUpdates: vi.fn(() => Promise.resolve(null)), - })); - vi.mock('./utils/cleanup.js', () => ({ cleanupCheckpoints: vi.fn(() => Promise.resolve()), registerCleanup: vi.fn(), @@ -1302,7 +1333,6 @@ describe('startInteractiveUI', () => { it('should perform all startup tasks in correct order', async () => { const { getCliVersion } = await import('./utils/version.js'); - const { checkForUpdates } = await import('./ui/utils/updateCheck.js'); const { registerCleanup } = await import('./utils/cleanup.js'); const mockInitializationResult = { @@ -1328,15 +1358,14 @@ describe('startInteractiveUI', () => { const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0]; expect(typeof cleanupFn).toBe('function'); - // checkForUpdates should be called asynchronously (not waited for) - // We need a small delay to let it execute - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(checkForUpdates).toHaveBeenCalledTimes(1); + expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( + mockConfig, + mockSettings, + { connectIde: true }, + ); }); - it('should not call checkForUpdates when enableAutoUpdate is false', async () => { - const { checkForUpdates } = await import('./ui/utils/updateCheck.js'); - + it('delegates auto-update gating to post-render prefetch', async () => { const settingsWithAutoUpdateDisabled = { merged: { general: { @@ -1363,9 +1392,10 @@ describe('startInteractiveUI', () => { mockInitializationResult, ); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // checkForUpdates should NOT be called when enableAutoUpdate is false - expect(checkForUpdates).not.toHaveBeenCalled(); + expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( + mockConfig, + settingsWithAutoUpdateDisabled, + { connectIde: true }, + ); }); }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 7620b4e4281..860edf5fe4a 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -47,6 +47,7 @@ import { buildStartupWorktreeNotice, type StartupWorktreeContext, } from './startup/worktreeStartup.js'; +import { startEarlyStartupPrefetches } from './startup/startup-prefetch.js'; import { cleanupCheckpoints, registerCleanup, @@ -71,7 +72,6 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { initializeWarningHandler } from './utils/warningHandler.js'; import { writeStderrLine } from './utils/stdioHelpers.js'; import { getHeadlessYoloSafetyWarning } from './utils/headlessSafetyWarnings.js'; -import { preconnectApi } from './utils/apiPreconnect.js'; import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; const debugLogger = createDebugLogger('STARTUP'); @@ -683,20 +683,7 @@ export async function main() { // This ensures MCP server subprocesses are properly terminated on exit registerCleanup(() => config.shutdown()); - // Startup optimization: preconnect API to warm TCP+TLS connection - // Fires early; cost is one HEAD request even for local-only commands - try { - const modelsConfig = config.getModelsConfig(); - const authType = modelsConfig.getCurrentAuthType(); - const resolvedBaseUrl = modelsConfig.getGenerationConfig().baseUrl; - const proxy = config.getProxy(); - preconnectApi(authType, { resolvedBaseUrl, proxy }); - } catch (error) { - // If we can't get authType, skip preconnect - it's optional optimization - debugLogger.debug( - `Preconnect skipped due to error getting authType: ${error}`, - ); - } + startEarlyStartupPrefetches(config); const wasRaw = process.stdin.isRaw; let kittyProtocolDetectionComplete: Promise | undefined; @@ -760,7 +747,11 @@ export async function main() { // For stream-json mode, defer config.initialize() until after the initialize control request // For other modes, initialize normally const { initializeApp } = await import('./core/initializer.js'); - const initializationResult = await initializeApp(config, settings); + const deferIdeConnection = + config.isInteractive() && !config.getExperimentalZedIntegration(); + const initializationResult = await initializeApp(config, settings, { + deferIdeConnection, + }); profileCheckpoint('after_initialize_app'); if (config.getExperimentalZedIntegration()) { @@ -771,25 +762,6 @@ export async function main() { process.exit(0); } - // Background housekeeping: file-history cleanup and (future) other - // periodic disk maintenance. Interactive-only — serve/SDK/ACP modes - // don't create the file-history dirs this cleans, so they skip. - // Dynamic import keeps --help / one-shot --prompt paths from loading - // this code at all. Timers inside are .unref()'d so they never block - // process exit. - if (config.isInteractive()) { - // .catch() is intentional: a dynamic-import or module-init failure - // (theoretically near-impossible — the module has no top-level side - // effects — but defense in depth matches the runPass try/catch in - // scheduler.ts) becomes a swallowed log instead of an unhandled - // promise rejection that crashes the REPL. - void import('./utils/housekeeping/scheduler.js') - .then((m) => m.startBackgroundHousekeeping(config, settings)) - .catch((err) => { - debugLogger.warn('failed to start background housekeeping:', err); - }); - } - let input = config.getQuestion(); const startupWarnings = [ ...new Set([ diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts new file mode 100644 index 00000000000..c2da90f90a7 --- /dev/null +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2026 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../config/settings.js'; +import { + startEarlyStartupPrefetches, + startPostRenderPrefetches, +} from './startup-prefetch.js'; + +const mockDebug = vi.hoisted(() => vi.fn()); +const mockWarn = vi.hoisted(() => vi.fn()); +const mockPreconnectApi = vi.hoisted(() => vi.fn()); +const mockRecordStartupEvent = vi.hoisted(() => vi.fn()); +const mockCheckForUpdates = vi.hoisted(() => vi.fn()); +const mockHandleAutoUpdate = vi.hoisted(() => vi.fn()); +const mockConnectIdeForStartup = vi.hoisted(() => vi.fn()); +const mockStartBackgroundHousekeeping = vi.hoisted(() => vi.fn()); + +vi.mock('@qwen-code/qwen-code-core', () => ({ + createDebugLogger: () => ({ + debug: mockDebug, + warn: mockWarn, + }), +})); + +vi.mock('../utils/apiPreconnect.js', () => ({ + preconnectApi: (...args: unknown[]) => mockPreconnectApi(...args), +})); + +vi.mock('../utils/startupProfiler.js', () => ({ + recordStartupEvent: (...args: unknown[]) => mockRecordStartupEvent(...args), +})); + +vi.mock('../ui/utils/updateCheck.js', () => ({ + checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args), +})); + +vi.mock('../utils/handleAutoUpdate.js', () => ({ + handleAutoUpdate: (...args: unknown[]) => mockHandleAutoUpdate(...args), +})); + +vi.mock('../core/initializer.js', () => ({ + connectIdeForStartup: (...args: unknown[]) => + mockConnectIdeForStartup(...args), +})); + +vi.mock('../utils/housekeeping/scheduler.js', () => ({ + startBackgroundHousekeeping: (...args: unknown[]) => + mockStartBackgroundHousekeeping(...args), +})); + +function makeConfig(overrides: Partial = {}): Config { + return { + getModelsConfig: () => ({ + getCurrentAuthType: () => 'openai', + getGenerationConfig: () => ({ baseUrl: 'https://api.openai.com/v1' }), + }), + getProxy: () => 'http://proxy.example', + getProjectRoot: () => '/repo', + getIdeMode: () => true, + ...overrides, + } as unknown as Config; +} + +function makeSettings( + enableAutoUpdate: boolean | undefined = undefined, +): LoadedSettings { + return { + merged: { + general: enableAutoUpdate === undefined ? {} : { enableAutoUpdate }, + }, + } as LoadedSettings; +} + +describe('startupPrefetch', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCheckForUpdates.mockResolvedValue(null); + mockConnectIdeForStartup.mockResolvedValue(undefined); + }); + + it('starts API preconnect with resolved auth config', () => { + const config = makeConfig(); + + startEarlyStartupPrefetches(config); + + expect(mockPreconnectApi).toHaveBeenCalledWith('openai', { + resolvedBaseUrl: 'https://api.openai.com/v1', + proxy: 'http://proxy.example', + }); + }); + + it('starts early prefetch only once per config', () => { + const config = makeConfig(); + + startEarlyStartupPrefetches(config); + startEarlyStartupPrefetches(config); + + expect(mockPreconnectApi).toHaveBeenCalledTimes(1); + }); + + it('skips preconnect errors without throwing', () => { + const config = makeConfig({ + getModelsConfig: () => { + throw new Error('bad config'); + }, + } as Partial); + + expect(() => startEarlyStartupPrefetches(config)).not.toThrow(); + expect(mockPreconnectApi).not.toHaveBeenCalled(); + expect(mockDebug).toHaveBeenCalled(); + }); + + it('starts post-render tasks without awaiting completion', async () => { + const config = makeConfig(); + const updatePromise = new Promise(() => {}); + mockCheckForUpdates.mockReturnValue(updatePromise); + + startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); + + await vi.dynamicImportSettled(); + + expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); + expect(mockConnectIdeForStartup).toHaveBeenCalledWith(config); + }); + + it('does not run update check when auto-update is disabled', async () => { + const config = makeConfig(); + + startPostRenderPrefetches(config, makeSettings(false), { + connectIde: true, + }); + + await vi.dynamicImportSettled(); + + expect(mockCheckForUpdates).not.toHaveBeenCalled(); + expect(mockConnectIdeForStartup).toHaveBeenCalledWith(config); + }); + + it('requires connectIde option before connecting IDE', async () => { + const config = makeConfig(); + + startPostRenderPrefetches(config, makeSettings()); + + await vi.dynamicImportSettled(); + + expect(mockConnectIdeForStartup).not.toHaveBeenCalled(); + }); + + it('does not connect IDE when IDE mode is disabled', async () => { + const config = makeConfig({ getIdeMode: () => false } as Partial); + + startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); + + await vi.dynamicImportSettled(); + + expect(mockConnectIdeForStartup).not.toHaveBeenCalled(); + }); + + it('swallows deferred task failures', async () => { + const config = makeConfig(); + mockCheckForUpdates.mockRejectedValue(new Error('network down')); + + expect(() => + startPostRenderPrefetches(config, makeSettings()), + ).not.toThrow(); + + await vi.dynamicImportSettled(); + + expect(mockWarn).toHaveBeenCalledWith('update_check failed: network down'); + }); + + it('starts post-render prefetch only once per config', async () => { + const config = makeConfig(); + + startPostRenderPrefetches(config, makeSettings()); + startPostRenderPrefetches(config, makeSettings()); + + await vi.dynamicImportSettled(); + + expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts new file mode 100644 index 00000000000..d5b8096efa3 --- /dev/null +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../config/settings.js'; +import { preconnectApi } from '../utils/apiPreconnect.js'; +import { recordStartupEvent } from '../utils/startupProfiler.js'; + +const debugLogger = createDebugLogger('STARTUP_PREFETCH'); + +const earlyStarted = new WeakSet(); +const postRenderStarted = new WeakSet(); + +/** + * Starts a best-effort startup task without adding it to the caller's + * critical path. Failures are recorded for diagnostics and otherwise + * swallowed so optional prefetch work cannot break REPL startup. + */ +function runDeferredTask(name: string, task: () => Promise | void): void { + recordStartupEvent('startup_prefetch_started', { name }); + void Promise.resolve() + .then(task) + .then(() => { + recordStartupEvent('startup_prefetch_completed', { name }); + }) + .catch((err) => { + recordStartupEvent('startup_prefetch_failed', { name }); + debugLogger.warn( + `${name} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); +} + +/** + * Starts pre-render startup prefetches that benefit from maximum lead time. + * + * This runs after `loadCliConfig()` has produced a Config, but before + * `initializeApp()` and UI rendering. Keep this phase limited to work that is + * cheap to start, independent of Ink/React, and safe to ignore on failure. + */ +export function startEarlyStartupPrefetches(config: Config): void { + if (earlyStarted.has(config)) return; + earlyStarted.add(config); + + try { + const modelsConfig = config.getModelsConfig(); + const authType = modelsConfig.getCurrentAuthType(); + const resolvedBaseUrl = modelsConfig.getGenerationConfig().baseUrl; + const proxy = config.getProxy(); + preconnectApi(authType, { resolvedBaseUrl, proxy }); + recordStartupEvent('startup_prefetch_started', { name: 'api_preconnect' }); + } catch (error) { + debugLogger.debug( + `Preconnect skipped due to error getting authType: ${error}`, + ); + } +} + +/** + * Starts post-render startup prefetches for ordinary interactive TUI sessions. + * + * This runs immediately after Ink's `render()` returns (`first_paint`). Tasks + * here may load heavier modules or perform network/IPC work, but they must not + * affect `startInteractiveUI()` success. `connectIde` is opt-in so headless, + * stream-json, and ACP/Zed paths can keep their awaited IDE startup semantics. + */ +export function startPostRenderPrefetches( + config: Config, + settings: LoadedSettings, + options: { connectIde?: boolean } = {}, +): void { + if (postRenderStarted.has(config)) return; + postRenderStarted.add(config); + + if (settings.merged.general?.enableAutoUpdate !== false) { + runDeferredTask('update_check', async () => { + const [{ checkForUpdates }, { handleAutoUpdate }] = await Promise.all([ + import('../ui/utils/updateCheck.js'), + import('../utils/handleAutoUpdate.js'), + ]); + const info = await checkForUpdates(); + handleAutoUpdate(info, settings, config.getProjectRoot()); + }); + } + + if (options.connectIde && config.getIdeMode()) { + runDeferredTask('ide_connect', async () => { + const { connectIdeForStartup } = await import('../core/initializer.js'); + await connectIdeForStartup(config); + }); + } + + runDeferredTask('background_housekeeping', async () => { + const { startBackgroundHousekeeping } = await import( + '../utils/housekeeping/scheduler.js' + ); + startBackgroundHousekeeping(config, settings); + }); +} diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index 71662dcae18..7f053e5af67 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -26,15 +26,14 @@ import { VimModeProvider } from './contexts/VimModeContext.js'; import { AgentViewProvider } from './contexts/AgentViewContext.js'; import { BackgroundTaskViewProvider } from './contexts/BackgroundTaskViewContext.js'; import { useKittyKeyboardProtocol } from './hooks/useKittyKeyboardProtocol.js'; -import { checkForUpdates } from './utils/updateCheck.js'; import { disableKittyProtocol } from './utils/kittyProtocolDetector.js'; import { installTerminalRedrawOptimizer } from './utils/terminalRedrawOptimizer.js'; import { installSynchronizedOutput } from './utils/synchronizedOutput.js'; -import { handleAutoUpdate } from '../utils/handleAutoUpdate.js'; import { registerCleanup } from '../utils/cleanup.js'; import { stopAndGetCapturedInput } from '../utils/earlyInputCapture.js'; import { profileCheckpoint } from '../utils/startupProfiler.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; +import { startPostRenderPrefetches } from '../startup/startup-prefetch.js'; import { computeWindowTitle, writeTerminalTitle, @@ -195,19 +194,7 @@ export async function startInteractiveUI( // after this — it carries the `config_initialize_*` and // `input_enabled` checkpoints that complete the first-screen picture. profileCheckpoint('first_paint'); - - // Check for updates only if enableAutoUpdate is not explicitly disabled. - // Using !== false ensures updates are enabled by default when undefined. - if (settings.merged.general?.enableAutoUpdate !== false) { - checkForUpdates() - .then((info) => { - handleAutoUpdate(info, settings, config.getProjectRoot()); - }) - .catch((err) => { - // Silently ignore update check errors. - debugLogger.warn(`Update check failed: ${err}`); - }); - } + startPostRenderPrefetches(config, settings, { connectIde: true }); registerCleanup(async () => { remoteInputWatcher?.shutdown(); From 66b9b0b4477152e7fe03af27dba684a909eebdfe Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Fri, 3 Jul 2026 09:15:16 +0800 Subject: [PATCH 02/11] fix(cli): await IDE for prompt-interactive startup --- packages/cli/src/gemini.test.tsx | 109 +++++++++++++++++++++++++++++++ packages/cli/src/gemini.tsx | 6 +- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 0002e69fcec..e9757167724 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1053,6 +1053,115 @@ describe('gemini.tsx main function kitty protocol', () => { ); }); + it('should await IDE connection when interactive mode has an initial prompt', async () => { + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + const initializerModule = await import('./core/initializer.js'); + const initializeAppSpy = vi + .spyOn(initializerModule, 'initializeApp') + .mockResolvedValue({ + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }); + vi.mocked(loadCliConfig).mockResolvedValue({ + isInteractive: () => true, + getQuestion: () => 'hello from prompt-interactive', + getSandbox: () => false, + getDebugMode: () => false, + getListExtensions: () => false, + getMcpServers: () => ({}), + getTopTierMcpServers: () => undefined, + initialize: vi.fn(), + waitForMcpReady: vi.fn().mockResolvedValue(undefined), + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, + getScreenReader: () => false, + getGeminiMdFileCount: () => 0, + getWarnings: () => [], + isSafeMode: () => false, + getModelsConfig: () => ({ getCurrentAuthType: () => null }), + getUsageStatisticsEnabled: () => true, + getSessionId: () => 'test-session-id', + } as unknown as Config); + vi.mocked(loadSettings).mockReturnValue({ + errors: [], + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + migrationWarnings: [], + getUserHooks: () => undefined, + getProjectHooks: () => undefined, + } as never); + vi.mocked(parseArguments).mockResolvedValue({ + model: undefined, + sandbox: undefined, + sandboxImage: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + systemPrompt: undefined, + appendSystemPrompt: undefined, + query: undefined, + yolo: undefined, + bare: undefined, + approvalMode: undefined, + telemetry: undefined, + telemetryTarget: undefined, + telemetryOtlpEndpoint: undefined, + telemetryOtlpProtocol: undefined, + telemetryLogPrompts: undefined, + telemetryOutfile: undefined, + allowedMcpServerNames: undefined, + mcpConfig: undefined, + allowedTools: undefined, + acp: undefined, + experimentalAcp: undefined, + extensions: undefined, + listExtensions: undefined, + openaiLogging: undefined, + openaiApiKey: undefined, + openaiBaseUrl: undefined, + openaiLoggingDir: undefined, + proxy: undefined, + includeDirectories: undefined, + screenReader: undefined, + inputFormat: undefined, + outputFormat: undefined, + includePartialMessages: undefined, + continue: undefined, + resume: undefined, + coreTools: undefined, + excludeTools: undefined, + disabledSlashCommands: undefined, + authType: undefined, + maxSessionTurns: undefined, + maxWallTime: undefined, + maxToolCalls: undefined, + experimentalLsp: undefined, + channel: undefined, + chatRecording: undefined, + sessionId: undefined, + }); + + await main(); + + expect(initializeAppSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + { + deferIdeConnection: false, + }, + ); + }); + it('should run cleanup before exiting on interactive SIGINT', async () => { const { loadCliConfig, parseArguments } = await import( './config/config.js' diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 860edf5fe4a..08d7cebe62b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -747,8 +747,11 @@ export async function main() { // For stream-json mode, defer config.initialize() until after the initialize control request // For other modes, initialize normally const { initializeApp } = await import('./core/initializer.js'); + let input = config.getQuestion(); const deferIdeConnection = - config.isInteractive() && !config.getExperimentalZedIntegration(); + config.isInteractive() && + !config.getExperimentalZedIntegration() && + !input; const initializationResult = await initializeApp(config, settings, { deferIdeConnection, }); @@ -762,7 +765,6 @@ export async function main() { process.exit(0); } - let input = config.getQuestion(); const startupWarnings = [ ...new Set([ ...(config.isSafeMode() From 5f8036b85bd005cab08a98264cb8d5325451e7c8 Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Sat, 4 Jul 2026 12:26:53 +0800 Subject: [PATCH 03/11] perf(cli): defer interactive telemetry startup --- .../fire-and-forget-startup-prefetch.md | 42 ++++++++++++----- packages/cli/src/config/config.ts | 1 + packages/cli/src/gemini.test.tsx | 45 ++++++++++++++++++- packages/cli/src/gemini.tsx | 1 + .../cli/src/startup/startup-prefetch.test.ts | 43 ++++++++++++++++++ packages/cli/src/startup/startup-prefetch.ts | 14 +++++- packages/cli/src/ui/startInteractiveUI.tsx | 10 ++++- packages/core/src/config/config.test.ts | 14 ++++++ packages/core/src/config/config.ts | 10 ++++- 9 files changed, 163 insertions(+), 17 deletions(-) diff --git a/docs/design/fire-and-forget-startup-prefetch.md b/docs/design/fire-and-forget-startup-prefetch.md index 594bb4cf695..a32ddbff541 100644 --- a/docs/design/fire-and-forget-startup-prefetch.md +++ b/docs/design/fire-and-forget-startup-prefetch.md @@ -42,9 +42,10 @@ flowchart TD 现状判断: - `initializeApp()` 仍在首屏前串行执行 i18n、auth、theme validation、IDE client connection。 -- auth 和 i18n 必须留在首屏前;IDE connection 对普通 TUI 的首屏和输入可用性不是硬依赖,可以在普通 TUI 路径延后;但对 `qwen -p`、stream-json、ACP/Zed 这类没有 TUI post-render 挂点或首个请求需要 IDE context/status 的路径,必须继续在首个请求前 await。 +- auth 和 i18n 必须留在首屏前;IDE connection 对无初始 prompt 的普通 TUI 首屏不是硬依赖,可以在普通 TUI 路径延后;但对 `qwen -i "prompt"`、`qwen -p`、stream-json、ACP/Zed 这类没有安全 post-render 空窗或首个请求需要 IDE context/status 的路径,必须继续在首个请求前 await。 - `checkForUpdates()` 已在 `startInteractiveUI()` 的 render 后 fire-and-forget,但逻辑散落在 UI 启动函数里。 - `preconnectApi()` 已是 fire-and-forget,应该保留尽早触发,但纳入统一调度。 +- telemetry SDK init 以前在 `Config` 构造期同步发生;普通 interactive TUI 可以延后到 render 后,非交互路径仍保留首个请求前初始化语义。 - interactive 路径中,`config.initialize()` 已在 React mount 后执行;MCP discovery 已在 core 内部后台运行,并由 AppContainer 批量刷新工具列表。 - non-interactive 路径仍需要等待 `config.waitForMcpReady()`,否则第一个 prompt 可能看不到 MCP 工具,造成脚本行为回退。 @@ -57,7 +58,6 @@ flowchart LR subgraph CLI[CLI startup] G[loadCliConfig] --> EP[startEarlyStartupPrefetches] EP --> PC[API preconnect] - EP --> HK[background housekeeping import] G --> IA[initializeAppCritical] G --> HI[initializeAppWithAwaitedIde for headless / stream-json / ACP] IA --> UI[startInteractiveUI] @@ -67,6 +67,8 @@ flowchart LR SP[startPostRenderPrefetches] SP --> UP[update check] SP --> IDE[IDE client connect only for ordinary TUI] + SP --> OTEL[telemetry SDK init for interactive TUI] + SP --> HK[background housekeeping import] SP --> PROF[profile async task events] end @@ -99,12 +101,13 @@ sequenceDiagram Main->>Main: loadCliConfig Main->>Prefetch: startEarlyStartupPrefetches(config) Prefetch-->>Prefetch: void preconnectApi() - Main->>Main: await initializeAppCritical(deferIdeConnection=true for ordinary TUI) + Main->>Main: await initializeAppCritical(deferIdeConnection=true for ordinary TUI without initial prompt) Main->>UI: startInteractiveUI(...) UI->>UI: render() - UI->>Prefetch: startPostRenderPrefetches(config, settings) + UI->>Prefetch: startPostRenderPrefetches(config, settings, options) Prefetch-->>Prefetch: void checkForUpdates() Prefetch-->>Prefetch: void connectIdeClient() for ordinary TUI only + Prefetch-->>Prefetch: void initializeTelemetry() for interactive TUI App->>App: await config.initialize() App->>MCP: start background discovery App->>App: input_enabled @@ -123,7 +126,7 @@ startEarlyStartupPrefetches(config: Config): void; startPostRenderPrefetches( config: Config, settings: LoadedSettings, - options?: { connectIde?: boolean }, + options?: { connectIde?: boolean; initializeTelemetry?: boolean }, ): void; ``` @@ -155,7 +158,8 @@ startPostRenderPrefetches( 首批纳入: - update check:迁移现有 `checkForUpdates().then(handleAutoUpdate)` 逻辑,保留 `settings.merged.general?.enableAutoUpdate !== false` 的门控。 -- IDE client connection:只在普通 interactive TUI 路径中移到 post-render prefetch。调用方必须显式传入 `connectIde: true`,并且调度器内部仍需检查 `config.getIdeMode()`。非交互、stream-json、ACP/Zed 不通过这个入口延后 IDE 连接。 +- IDE client connection:只在无初始 prompt 的普通 interactive TUI 路径中移到 post-render prefetch。调用方必须显式传入 `connectIde: true`,并且调度器内部仍需检查 `config.getIdeMode()`。`qwen -i "prompt"`、非交互、stream-json、ACP/Zed 不通过这个入口延后 IDE 连接。 +- telemetry SDK init:只在 interactive TUI 路径中移到 post-render prefetch。`Config` 仍保留 telemetry settings,但通过 `deferTelemetryInitialization` 跳过构造期 SDK side effect;post-render prefetch 通过 `initializeTelemetry(config)` 启动 SDK。非交互、stream-json、ACP/Zed 不延后。 - background housekeeping:可从 `gemini.tsx` 迁移到 post-render prefetch,使所有后台启动任务有统一入口;仍限定 interactive,仍保持 dynamic import 和错误吞噬。 这些任务都不能影响 `startInteractiveUI()` 的返回值,也不能向 TUI stderr 写用户可见错误。失败只进入 debug log。 @@ -197,18 +201,19 @@ interface InitializeAppOptions { ```ts const deferIdeConnection = - config.isInteractive() && !config.getExperimentalZedIntegration(); + config.isInteractive() && !config.getExperimentalZedIntegration() && !input; const initializationResult = await initializeApp(config, settings, { deferIdeConnection, }); ``` -随后只有当 `deferIdeConnection === true` 时,`startInteractiveUI()` 才通过 `startPostRenderPrefetches(..., { connectIde: true })` fire-and-forget 启动 IDE 连接。 +随后只有当 `deferIdeConnection === true` 时,`startInteractiveUI()` 才通过 `startPostRenderPrefetches(..., { connectIde: true })` fire-and-forget 启动 IDE 连接;prompt-interactive 因为会自动提交首个问题,继续在 render 前 await IDE,并传入 `connectIde: false` 避免 post-render 重复连接。 这个分流修正 review 指出的兼容性风险: - 普通 interactive TUI:IDE socket/IPC 连接不再阻塞首屏。 +- `qwen -i "prompt"`:继续在首个自动提交请求前 await IDE 连接,且 post-render 不重复连接。 - `qwen -p` / piped stdin:继续在首个模型请求前 await IDE 连接。 - stream-json:继续在 session/control 请求处理前完成 IDE 连接。 - ACP/Zed:继续保留 awaited IDE startup,避免首个请求缺 IDE context/status。 @@ -238,6 +243,7 @@ non-interactive / stream-json / ACP: 第一类是首屏前关键路径缩短: - 普通 interactive TUI 的 IDE client connection 不再阻塞首屏;收益取决于 IDE socket/IPC 连接耗时,预期为几十毫秒到数百毫秒。 +- 普通 interactive TUI 的 telemetry SDK init 不再阻塞首屏;收益取决于 OTel SDK/exporter 构造成本,通常是小到中等的同步启动开销。 - update check、housekeeping、preconnect 等任务有统一 fire-and-forget 入口,不会被未来维护误放回 awaited path。 第二类是首个 API 请求收益: @@ -252,6 +258,7 @@ non-interactive / stream-json / ACP: ### 风险 - 普通 TUI 的 IDE 能力可能从“首屏前已连接”变为“首屏后很快连接”。缓解方式:只在普通 interactive TUI 路径延后;非交互、stream-json、ACP/Zed 保持首个请求前 awaited 连接。 +- render 前 telemetry event 可能在 SDK 未初始化时被 no-op 丢弃。缓解方式:只延后 interactive TUI;非交互首个请求相关 telemetry 仍保持原语义,不新增缓冲队列。 - deferred task 失败可能不明显。缓解方式:统一 wrapper 记录 debug log 和 profiler async event。 - 迁移 update/preconnect 时可能改变原有门控。缓解方式:逐字保留现有 settings/env 条件。 - 过度 defer 可能导致首个用户输入依赖的能力未就绪。缓解方式:auth、config construction、permissions、hooks、memory、tool registry、non-interactive MCP ready 全部保持 awaited。 @@ -288,6 +295,8 @@ non-interactive / stream-json / ACP: - `options.connectIde === true` 且 `config.getIdeMode() === true` 时启动 IDE connect 并调用 `logIdeConnection()`。 - `options.connectIde !== true` 时不触发 IDE connect。 - `config.getIdeMode() === false` 时即使 `options.connectIde === true` 也不触发 IDE connect。 +- `options.initializeTelemetry === true` 时启动 telemetry SDK init。 +- `options.initializeTelemetry !== true` 时不触发 telemetry SDK init。 - deferred task reject 不会让 public API throw,只写 debug log。 ### `packages/cli/src/core/initializer.test.ts` @@ -308,7 +317,9 @@ non-interactive / stream-json / ACP: 覆盖: - Ink `render()` 返回并记录 `first_paint` 后,调用 `startPostRenderPrefetches(config, settings)`。 -- 普通 TUI 路径传入 `{ connectIde: true }`;非 TUI 路径不通过 `startInteractiveUI()` 触发 IDE prefetch。 +- 普通 TUI 路径传入 `{ connectIde: true, initializeTelemetry: true }`。 +- 当 prompt-interactive 已在 render 前 await IDE 时,传入 `{ connectIde: false, initializeTelemetry: true }`,避免重复 IDE connect。 +- 非 TUI 路径不通过 `startInteractiveUI()` 触发 IDE/telemetry post-render prefetch。 - post-render prefetch reject 不会导致 `startInteractiveUI()` reject。 - update check 从 `startInteractiveUI()` 内联逻辑移走后,不再被直接调用。 @@ -317,9 +328,17 @@ non-interactive / stream-json / ACP: 调整并新增: - 普通 interactive TUI 调用 `initializeApp(config, settings, { deferIdeConnection: true })`,并在 post-render prefetch 中连接 IDE。 +- prompt-interactive 调用 `initializeApp(config, settings, { deferIdeConnection: false })`,并且 post-render prefetch 不再连接 IDE。 - `qwen -p` / piped stdin / stream-json 调用 `initializeApp(config, settings, { deferIdeConnection: false })` 或使用默认值,确保首个请求前 IDE 已连接。 - ACP/Zed 路径不启用 IDE deferred prefetch,继续走 awaited IDE startup。 +### `packages/core/src/config/config.test.ts` + +覆盖: + +- telemetry enabled 且未传 `deferTelemetryInitialization` 时,`Config` 构造期仍调用 `initializeTelemetry(config)`。 +- telemetry enabled 且 `deferTelemetryInitialization === true` 时,`Config` 构造期不调用 `initializeTelemetry(config)`,但 `config.getTelemetryEnabled()` 仍为 true。 + ### 回归测试 建议执行: @@ -327,13 +346,14 @@ non-interactive / stream-json / ACP: ```bash cd packages/cli && npx vitest run src/core/initializer.test.ts src/startup/startup-prefetch.test.ts cd packages/cli && npx vitest run src/gemini.test.tsx -cd packages/core && npx vitest run src/config/config.test.ts src/tools/tool-registry.test.ts +cd packages/core && npx vitest run src/config/config.test.ts -t "telemetry" ``` ## 验收标准 -- interactive REPL 首屏不等待 IDE connection、update check、housekeeping。 +- interactive REPL 首屏不等待 IDE connection、telemetry init、update check、housekeeping。 - non-interactive、stream-json、ACP/Zed 在首个请求前仍 await IDE connection。 +- non-interactive、stream-json、ACP/Zed 不延后 telemetry SDK init。 - API preconnect 仍在 `loadCliConfig()` 后尽早 fire-and-forget。 - auth、config、permissions、hooks、memory 等关键正确性初始化仍在需要的位置 await。 - non-interactive 首个 prompt 仍等待 MCP ready。 diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ab9204d7b48..8d2a777a932 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2011,6 +2011,7 @@ export async function loadCliConfig( showResponseTokensPerSecond: settings.ui?.showResponseTokensPerSecond === true, telemetry: telemetrySettings, + deferTelemetryInitialization: interactive && !isAcpMode, outboundCorrelation: settings.outboundCorrelation, usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, clearContextOnIdle: settings.context?.clearContextOnIdle, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index e9757167724..de2ee6478b0 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1051,6 +1051,14 @@ describe('gemini.tsx main function kitty protocol', () => { deferIdeConnection: true, }, ); + expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + { + connectIde: true, + initializeTelemetry: true, + }, + ); }); it('should await IDE connection when interactive mode has an initial prompt', async () => { @@ -1145,6 +1153,7 @@ describe('gemini.tsx main function kitty protocol', () => { maxSessionTurns: undefined, maxWallTime: undefined, maxToolCalls: undefined, + maxSubagentDepth: undefined, experimentalLsp: undefined, channel: undefined, chatRecording: undefined, @@ -1160,6 +1169,14 @@ describe('gemini.tsx main function kitty protocol', () => { deferIdeConnection: false, }, ); + expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + { + connectIde: false, + initializeTelemetry: true, + }, + ); }); it('should run cleanup before exiting on interactive SIGINT', async () => { @@ -1470,7 +1487,31 @@ describe('startInteractiveUI', () => { expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( mockConfig, mockSettings, - { connectIde: true }, + { connectIde: true, initializeTelemetry: true }, + ); + }); + + it('can skip post-render IDE connection after prompt-interactive awaited it', async () => { + const mockInitializationResult = { + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }; + + await startInteractiveUI( + mockConfig, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + mockInitializationResult, + { postRenderConnectIde: false }, + ); + + expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( + mockConfig, + mockSettings, + { connectIde: false, initializeTelemetry: true }, ); }); @@ -1504,7 +1545,7 @@ describe('startInteractiveUI', () => { expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( mockConfig, settingsWithAutoUpdateDisabled, - { connectIde: true }, + { connectIde: true, initializeTelemetry: true }, ); }); }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 08d7cebe62b..d8494625f91 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -842,6 +842,7 @@ export async function main() { startupWarnings, process.cwd(), initializationResult!, + { postRenderConnectIde: deferIdeConnection }, ); // Clean up corruption env vars so subsequent relaunch children // and subprocesses don't inherit stale state. diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index c2da90f90a7..c5868a61a53 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -19,6 +19,7 @@ const mockRecordStartupEvent = vi.hoisted(() => vi.fn()); const mockCheckForUpdates = vi.hoisted(() => vi.fn()); const mockHandleAutoUpdate = vi.hoisted(() => vi.fn()); const mockConnectIdeForStartup = vi.hoisted(() => vi.fn()); +const mockInitializeTelemetry = vi.hoisted(() => vi.fn()); const mockStartBackgroundHousekeeping = vi.hoisted(() => vi.fn()); vi.mock('@qwen-code/qwen-code-core', () => ({ @@ -26,6 +27,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ debug: mockDebug, warn: mockWarn, }), + initializeTelemetry: (...args: unknown[]) => mockInitializeTelemetry(...args), })); vi.mock('../utils/apiPreconnect.js', () => ({ @@ -162,6 +164,47 @@ describe('startupPrefetch', () => { expect(mockConnectIdeForStartup).not.toHaveBeenCalled(); }); + it('initializes telemetry when requested', async () => { + const config = makeConfig(); + + startPostRenderPrefetches(config, makeSettings(), { + initializeTelemetry: true, + }); + + await vi.dynamicImportSettled(); + + expect(mockInitializeTelemetry).toHaveBeenCalledWith(config); + }); + + it('does not initialize telemetry unless requested', async () => { + const config = makeConfig(); + + startPostRenderPrefetches(config, makeSettings()); + + await vi.dynamicImportSettled(); + + expect(mockInitializeTelemetry).not.toHaveBeenCalled(); + }); + + it('swallows telemetry initialization failures', async () => { + const config = makeConfig(); + mockInitializeTelemetry.mockImplementation(() => { + throw new Error('otel unavailable'); + }); + + expect(() => + startPostRenderPrefetches(config, makeSettings(), { + initializeTelemetry: true, + }), + ).not.toThrow(); + + await vi.dynamicImportSettled(); + + expect(mockWarn).toHaveBeenCalledWith( + 'telemetry_init failed: otel unavailable', + ); + }); + it('swallows deferred task failures', async () => { const config = makeConfig(); mockCheckForUpdates.mockRejectedValue(new Error('network down')); diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index d5b8096efa3..84da1cef143 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core'; +import { + createDebugLogger, + initializeTelemetry, + type Config, +} from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; import { preconnectApi } from '../utils/apiPreconnect.js'; import { recordStartupEvent } from '../utils/startupProfiler.js'; @@ -70,7 +74,7 @@ export function startEarlyStartupPrefetches(config: Config): void { export function startPostRenderPrefetches( config: Config, settings: LoadedSettings, - options: { connectIde?: boolean } = {}, + options: { connectIde?: boolean; initializeTelemetry?: boolean } = {}, ): void { if (postRenderStarted.has(config)) return; postRenderStarted.add(config); @@ -93,6 +97,12 @@ export function startPostRenderPrefetches( }); } + if (options.initializeTelemetry) { + runDeferredTask('telemetry_init', () => { + initializeTelemetry(config); + }); + } + runDeferredTask('background_housekeeping', async () => { const { startBackgroundHousekeeping } = await import( '../utils/housekeeping/scheduler.js' diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index 7f053e5af67..88f387d11ba 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -42,12 +42,17 @@ import { getCliVersion } from '../utils/version.js'; const debugLogger = createDebugLogger('STARTUP'); +export interface StartInteractiveUIOptions { + postRenderConnectIde?: boolean; +} + export async function startInteractiveUI( config: Config, settings: LoadedSettings, startupWarnings: string[], workspaceRoot: string = process.cwd(), initializationResult: InitializationResult, + options: StartInteractiveUIOptions = {}, ) { const version = await getCliVersion(); setWindowTitle(settings, basename(workspaceRoot)); @@ -194,7 +199,10 @@ export async function startInteractiveUI( // after this — it carries the `config_initialize_*` and // `input_enabled` checkpoints that complete the first-screen picture. profileCheckpoint('first_paint'); - startPostRenderPrefetches(config, settings, { connectIde: true }); + startPostRenderPrefetches(config, settings, { + connectIde: options.postRenderConnectIde ?? true, + initializeTelemetry: true, + }); registerCleanup(async () => { remoteInputWatcher?.shutdown(); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 3a53b5ca476..d7c248b56a4 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -29,6 +29,7 @@ import { DEFAULT_OTLP_ENDPOINT, SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH_LIMIT, QwenLogger, + initializeTelemetry, isTelemetrySdkInitialized, shutdownTelemetry, refreshSessionContext, @@ -3787,6 +3788,19 @@ describe('Server Config (config.ts)', () => { }; const config = new Config(paramsWithTelemetry); expect(config.getTelemetryEnabled()).toBe(true); + expect(initializeTelemetry).toHaveBeenCalledWith(config); + }); + + it('Config constructor should defer telemetry initialization when requested', () => { + const paramsWithTelemetry: ConfigParameters = { + ...baseParams, + telemetry: { enabled: true }, + deferTelemetryInitialization: true, + }; + const config = new Config(paramsWithTelemetry); + + expect(config.getTelemetryEnabled()).toBe(true); + expect(initializeTelemetry).not.toHaveBeenCalled(); }); it('Config shutdown should flush telemetry when SDK is initialized', async () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6413fd758ca..982c702e74c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -904,6 +904,11 @@ export interface ConfigParameters { accessibility?: AccessibilitySettings; showResponseTokensPerSecond?: boolean; telemetry?: TelemetrySettings; + /** + * Delay SDK startup for interactive render paths. Telemetry settings still + * remain readable from Config; only the global SDK side effect is deferred. + */ + deferTelemetryInitialization?: boolean; outboundCorrelation?: OutboundCorrelationSettings; gitCoAuthor?: GitCoAuthorParam; usageStatisticsEnabled?: boolean; @@ -1915,7 +1920,10 @@ export class Config { onModelChange: this.handleModelChange.bind(this), }); - if (this.telemetrySettings.enabled) { + if ( + this.telemetrySettings.enabled && + !params.deferTelemetryInitialization + ) { initializeTelemetry(this); } From e441f640b8e793604e4be2d120c7a8b78a9f160e Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Sat, 4 Jul 2026 17:11:45 +0800 Subject: [PATCH 04/11] test(cli): add missing assertions and Zed/ACP path coverage for startup prefetch Address three test coverage gaps identified during code review: - Assert mockStartEarlyStartupPrefetches in both kitty protocol tests (C1: API preconnect call was wired but never verified) - Add Zed/ACP integration test verifying deferIdeConnection is false when getExperimentalZedIntegration returns true (C2: Zed path was entirely untested) - Assert mockStartBackgroundHousekeeping in startup-prefetch test (C3: unconditional housekeeping dispatch was never verified) --- packages/cli/src/gemini.test.tsx | 141 ++++++++++++++++++ .../cli/src/startup/startup-prefetch.test.ts | 4 + 2 files changed, 145 insertions(+) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index de2ee6478b0..d83aec5ea10 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -31,6 +31,7 @@ const mockWriteStderrLine = vi.hoisted(() => vi.fn()); const mockHandleListExtensions = vi.hoisted(() => vi.fn()); const mockStartEarlyStartupPrefetches = vi.hoisted(() => vi.fn()); const mockStartPostRenderPrefetches = vi.hoisted(() => vi.fn()); +const mockRunAcpAgent = vi.hoisted(() => vi.fn()); describe('gemini import boundary', () => { it('does not statically import ACP or noninteractive auth branches', () => { @@ -148,6 +149,10 @@ vi.mock('./startup/startup-prefetch.js', () => ({ mockStartPostRenderPrefetches(...args), })); +vi.mock('./acp-integration/acpAgent.js', () => ({ + runAcpAgent: (...args: unknown[]) => mockRunAcpAgent(...args), +})); + vi.mock('./commands/extensions/list.js', () => ({ handleList: mockHandleListExtensions, })); @@ -1059,6 +1064,9 @@ describe('gemini.tsx main function kitty protocol', () => { initializeTelemetry: true, }, ); + expect(mockStartEarlyStartupPrefetches).toHaveBeenCalledWith( + expect.any(Object), + ); }); it('should await IDE connection when interactive mode has an initial prompt', async () => { @@ -1177,6 +1185,139 @@ describe('gemini.tsx main function kitty protocol', () => { initializeTelemetry: true, }, ); + expect(mockStartEarlyStartupPrefetches).toHaveBeenCalledWith( + expect.any(Object), + ); + }); + + it('should not defer IDE connection when Zed integration is enabled', async () => { + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + const initializerModule = await import('./core/initializer.js'); + const initializeAppSpy = vi + .spyOn(initializerModule, 'initializeApp') + .mockResolvedValue({ + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }); + vi.mocked(loadCliConfig).mockResolvedValue({ + isInteractive: () => true, + getQuestion: () => '', + getSandbox: () => false, + getDebugMode: () => false, + getListExtensions: () => false, + getMcpServers: () => ({}), + getTopTierMcpServers: () => undefined, + initialize: vi.fn(), + waitForMcpReady: vi.fn().mockResolvedValue(undefined), + getIdeMode: () => false, + getExperimentalZedIntegration: () => true, + getScreenReader: () => false, + getGeminiMdFileCount: () => 0, + getWarnings: () => [], + isSafeMode: () => false, + getModelsConfig: () => ({ getCurrentAuthType: () => null }), + getUsageStatisticsEnabled: () => true, + getSessionId: () => 'test-session-id', + } as unknown as Config); + vi.mocked(loadSettings).mockReturnValue({ + errors: [], + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + migrationWarnings: [], + getUserHooks: () => undefined, + getProjectHooks: () => undefined, + } as never); + vi.mocked(parseArguments).mockResolvedValue({ + model: undefined, + sandbox: undefined, + sandboxImage: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + systemPrompt: undefined, + appendSystemPrompt: undefined, + query: undefined, + yolo: undefined, + bare: undefined, + approvalMode: undefined, + telemetry: undefined, + telemetryTarget: undefined, + telemetryOtlpEndpoint: undefined, + telemetryOtlpProtocol: undefined, + telemetryLogPrompts: undefined, + telemetryOutfile: undefined, + allowedMcpServerNames: undefined, + mcpConfig: undefined, + allowedTools: undefined, + acp: undefined, + experimentalAcp: undefined, + extensions: undefined, + listExtensions: undefined, + openaiLogging: undefined, + openaiApiKey: undefined, + openaiBaseUrl: undefined, + openaiLoggingDir: undefined, + proxy: undefined, + includeDirectories: undefined, + screenReader: undefined, + inputFormat: undefined, + outputFormat: undefined, + includePartialMessages: undefined, + continue: undefined, + resume: undefined, + coreTools: undefined, + excludeTools: undefined, + disabledSlashCommands: undefined, + authType: undefined, + maxSessionTurns: undefined, + maxWallTime: undefined, + maxToolCalls: undefined, + maxSubagentDepth: undefined, + experimentalLsp: undefined, + channel: undefined, + chatRecording: undefined, + sessionId: undefined, + }); + + // Mock process.exit to throw instead of terminating the process + const originalExit = process.exit; + process.exit = ((code?: string | number | null | undefined) => { + throw new MockProcessExitError(code); + }) as unknown as typeof process.exit; + + try { + await main(); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } finally { + process.exit = originalExit; + } + + expect(initializeAppSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + { + deferIdeConnection: false, + }, + ); + expect(mockRunAcpAgent).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.any(Object), + ); + expect(mockStartEarlyStartupPrefetches).toHaveBeenCalledWith( + expect.any(Object), + ); }); it('should run cleanup before exiting on interactive SIGINT', async () => { diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index c5868a61a53..e2acd673e0e 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -129,6 +129,10 @@ describe('startupPrefetch', () => { expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); expect(mockConnectIdeForStartup).toHaveBeenCalledWith(config); + expect(mockStartBackgroundHousekeeping).toHaveBeenCalledWith( + config, + expect.any(Object), + ); }); it('does not run update check when auto-update is disabled', async () => { From 9e67e802b2d4ab8c769d6892f87f866a190aebee Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Sat, 4 Jul 2026 17:25:48 +0800 Subject: [PATCH 05/11] docs: move startup prefetch design doc to performance subdirectory --- docs/design/{ => performance}/fire-and-forget-startup-prefetch.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/design/{ => performance}/fire-and-forget-startup-prefetch.md (100%) diff --git a/docs/design/fire-and-forget-startup-prefetch.md b/docs/design/performance/fire-and-forget-startup-prefetch.md similarity index 100% rename from docs/design/fire-and-forget-startup-prefetch.md rename to docs/design/performance/fire-and-forget-startup-prefetch.md From 1957471e36923c972489eb78a7f309e59e11e47e Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Sat, 4 Jul 2026 17:29:43 +0800 Subject: [PATCH 06/11] docs: translate startup prefetch design doc to English --- .../fire-and-forget-startup-prefetch.md | 306 +++++++++--------- 1 file changed, 153 insertions(+), 153 deletions(-) diff --git a/docs/design/performance/fire-and-forget-startup-prefetch.md b/docs/design/performance/fire-and-forget-startup-prefetch.md index a32ddbff541..496352af48e 100644 --- a/docs/design/performance/fire-and-forget-startup-prefetch.md +++ b/docs/design/performance/fire-and-forget-startup-prefetch.md @@ -1,19 +1,19 @@ -# Fire-and-Forget Startup Prefetch 启动优化设计 +# Fire-and-Forget Startup Prefetch Optimization Design -## 背景与目标 +## Background and Goals -父 issue #3011 将 qwen-code 启动优化拆成多项子任务。当前仓库已经落地了部分基础能力: +The parent issue #3011 breaks down qwen-code startup optimization into multiple subtasks. The current repository has already landed several foundational capabilities: -- #3219:启动性能 profiler 已接入,支持 `QWEN_CODE_PROFILE_STARTUP=1` 输出启动阶段 JSON。 -- #3221:工具注册已改为 lazy factory,`Config.initialize()` 不再静态实例化所有工具。 -- #3223:API preconnect 已存在,当前在 `loadCliConfig()` 后以 fire-and-forget 方式触发。 -- early input capture、MCP 渐进发现、AppContainer 渲染后 `config.initialize()` 也已部分实现。 +- #3219: Startup performance profiler is integrated, supporting `QWEN_CODE_PROFILE_STARTUP=1` to output startup phase JSON. +- #3221: Tool registration has been converted to lazy factory; `Config.initialize()` no longer statically instantiates all tools. +- #3223: API preconnect already exists, currently triggered in a fire-and-forget fashion after `loadCliConfig()`. +- Early input capture, progressive MCP discovery, and `config.initialize()` after AppContainer render are also partially implemented. -#3222 的目标不是重做这些能力,而是把仍散落在启动路径中的非关键启动操作统一收敛成 fire-and-forget prefetch:首屏前只等待确实影响正确性的操作,首屏后启动不影响首次交互正确性的后台任务,同时保持非交互模式的兼容语义。 +The goal of #3222 is not to redo these capabilities, but to consolidate the non-critical startup operations still scattered across the startup path into a unified fire-and-forget prefetch layer: before first paint, only await operations that truly affect correctness; after first paint, launch background tasks that do not affect the correctness of the first interaction, while preserving compatible semantics for non-interactive modes. -## 现有启动流程 +## Current Startup Flow -当前交互式启动路径的关键流程如下: +The key flow of the current interactive startup path is as follows: ```mermaid flowchart TD @@ -39,19 +39,19 @@ flowchart TD T --> U[runNonInteractive] ``` -现状判断: +Current state assessment: -- `initializeApp()` 仍在首屏前串行执行 i18n、auth、theme validation、IDE client connection。 -- auth 和 i18n 必须留在首屏前;IDE connection 对无初始 prompt 的普通 TUI 首屏不是硬依赖,可以在普通 TUI 路径延后;但对 `qwen -i "prompt"`、`qwen -p`、stream-json、ACP/Zed 这类没有安全 post-render 空窗或首个请求需要 IDE context/status 的路径,必须继续在首个请求前 await。 -- `checkForUpdates()` 已在 `startInteractiveUI()` 的 render 后 fire-and-forget,但逻辑散落在 UI 启动函数里。 -- `preconnectApi()` 已是 fire-and-forget,应该保留尽早触发,但纳入统一调度。 -- telemetry SDK init 以前在 `Config` 构造期同步发生;普通 interactive TUI 可以延后到 render 后,非交互路径仍保留首个请求前初始化语义。 -- interactive 路径中,`config.initialize()` 已在 React mount 后执行;MCP discovery 已在 core 内部后台运行,并由 AppContainer 批量刷新工具列表。 -- non-interactive 路径仍需要等待 `config.waitForMcpReady()`,否则第一个 prompt 可能看不到 MCP 工具,造成脚本行为回退。 +- `initializeApp()` still executes i18n, auth, theme validation, and IDE client connection serially before first paint. +- Auth and i18n must remain before first paint; IDE connection is not a hard dependency for the first paint of a plain TUI without an initial prompt, and can be deferred on the plain TUI path. However, for paths like `qwen -i "prompt"`, `qwen -p`, stream-json, and ACP/Zed — which have no safe post-render window or whose first request needs IDE context/status — IDE connection must continue to be awaited before the first request. +- `checkForUpdates()` is already fire-and-forget after render in `startInteractiveUI()`, but the logic is scattered within the UI startup function. +- `preconnectApi()` is already fire-and-forget and should be kept triggering as early as possible, but brought under unified scheduling. +- Telemetry SDK init previously occurred synchronously during `Config` construction; for plain interactive TUI it can be deferred to after render, while non-interactive paths retain the pre-first-request initialization semantics. +- On the interactive path, `config.initialize()` already executes after React mount; MCP discovery already runs in the background inside core, with AppContainer batch-refreshing the tool list. +- The non-interactive path still needs to await `config.waitForMcpReady()`, otherwise the first prompt might not see MCP tools, causing scripted behavior to regress. -## 目标架构 +## Target Architecture -新增一个很小的启动预取调度层,统一管理“启动但不等待”的任务,按触发时机分为 early 和 post-render 两类。 +Introduce a small startup prefetch scheduling layer that uniformly manages "start but don't await" tasks, split into two categories by trigger timing: early and post-render. ```mermaid flowchart LR @@ -87,7 +87,7 @@ flowchart LR end ``` -新方案的交互式启动时序: +The interactive startup sequence under the new design: ```mermaid sequenceDiagram @@ -115,11 +115,11 @@ sequenceDiagram App-->>MCP: geminiClient.setTools() ``` -## 设计改动 +## Design Changes -### 1. 新增统一启动预取调度器 +### 1. New Unified Startup Prefetch Scheduler -新增 `packages/cli/src/startup/startup-prefetch.ts`,提供两个入口: +Add `packages/cli/src/startup/startup-prefetch.ts`, providing two entry points: ```ts startEarlyStartupPrefetches(config: Config): void; @@ -130,43 +130,43 @@ startPostRenderPrefetches( ): void; ``` -调度器只做三件事: +The scheduler does exactly three things: -- 按名称启动预取任务。 -- 用 `void task().catch(...)` 明确不等待、不抛出。 -- 记录 debug 日志和 profiler async event,便于验证任务是否在 render 前后启动。 +- Launches prefetch tasks by name. +- Uses `void task().catch(...)` to explicitly not await and not throw. +- Records debug logs and profiler async events to verify whether tasks are launched before or after render. -调度器需要保证每个阶段幂等,避免 React StrictMode、测试重复调用或异常重入导致同一任务启动多次。 +The scheduler must guarantee idempotency per phase, preventing React StrictMode, repeated test calls, or anomalous re-entries from launching the same task multiple times. -### 2. early prefetch:保留最大提前量 +### 2. Early Prefetch: Maximize Head Start -`startEarlyStartupPrefetches(config)` 在 `loadCliConfig()` 成功后立即调用。 +`startEarlyStartupPrefetches(config)` is called immediately after `loadCliConfig()` succeeds. -第一阶段只纳入 API preconnect: +The first phase includes only API preconnect: -- 从 `config.getModelsConfig()` 读取当前 auth type 和 resolved base URL。 -- 从 `config.getProxy()` 读取 proxy。 -- 调用现有 `preconnectApi(authType, { resolvedBaseUrl, proxy })`。 -- 保留现有环境门控:`QWEN_CODE_DISABLE_PRECONNECT`、sandbox、custom CA、非 Node runtime、无 proxy 等。 +- Reads the current auth type and resolved base URL from `config.getModelsConfig()`. +- Reads proxy from `config.getProxy()`. +- Calls the existing `preconnectApi(authType, { resolvedBaseUrl, proxy })`. +- Preserves existing environment gates: `QWEN_CODE_DISABLE_PRECONNECT`, sandbox, custom CA, non-Node runtime, no proxy, etc. -这部分不新增设置项。preconnect 失败只写 debug 日志,不影响启动。 +This adds no new configuration options. Preconnect failures only write debug logs and do not affect startup. -### 3. post-render prefetch:首屏后启动 +### 3. Post-Render Prefetch: Launch After First Paint -`startPostRenderPrefetches(config, settings)` 在 `startInteractiveUI()` 中 Ink `render()` 返回并记录 `first_paint` 后调用。 +`startPostRenderPrefetches(config, settings)` is called in `startInteractiveUI()` after Ink `render()` returns and `first_paint` is recorded. -首批纳入: +First batch includes: -- update check:迁移现有 `checkForUpdates().then(handleAutoUpdate)` 逻辑,保留 `settings.merged.general?.enableAutoUpdate !== false` 的门控。 -- IDE client connection:只在无初始 prompt 的普通 interactive TUI 路径中移到 post-render prefetch。调用方必须显式传入 `connectIde: true`,并且调度器内部仍需检查 `config.getIdeMode()`。`qwen -i "prompt"`、非交互、stream-json、ACP/Zed 不通过这个入口延后 IDE 连接。 -- telemetry SDK init:只在 interactive TUI 路径中移到 post-render prefetch。`Config` 仍保留 telemetry settings,但通过 `deferTelemetryInitialization` 跳过构造期 SDK side effect;post-render prefetch 通过 `initializeTelemetry(config)` 启动 SDK。非交互、stream-json、ACP/Zed 不延后。 -- background housekeeping:可从 `gemini.tsx` 迁移到 post-render prefetch,使所有后台启动任务有统一入口;仍限定 interactive,仍保持 dynamic import 和错误吞噬。 +- Update check: migrate the existing `checkForUpdates().then(handleAutoUpdate)` logic, preserving the `settings.merged.general?.enableAutoUpdate !== false` gate. +- IDE client connection: moved to post-render prefetch only on the plain interactive TUI path without an initial prompt. Callers must explicitly pass `connectIde: true`, and the scheduler internally still checks `config.getIdeMode()`. `qwen -i "prompt"`, non-interactive, stream-json, and ACP/Zed do not defer IDE connection through this entry point. +- Telemetry SDK init: moved to post-render prefetch only on the interactive TUI path. `Config` still retains telemetry settings, but skips the construction-time SDK side effect via `deferTelemetryInitialization`; post-render prefetch launches the SDK via `initializeTelemetry(config)`. Non-interactive, stream-json, and ACP/Zed do not defer. +- Background housekeeping: can be migrated from `gemini.tsx` to post-render prefetch, giving all background startup tasks a unified entry point; still limited to interactive, still uses dynamic import and error swallowing. -这些任务都不能影响 `startInteractiveUI()` 的返回值,也不能向 TUI stderr 写用户可见错误。失败只进入 debug log。 +None of these tasks may affect the return value of `startInteractiveUI()`, nor may they write user-visible errors to the TUI stderr. Failures only go to debug logs. -### 4. 拆分 `initializeApp()` 的关键路径,并保留非 TUI awaited IDE 连接 +### 4. Split `initializeApp()` Critical Path, Preserve Non-TUI Awaited IDE Connection -新增一个共享 helper,避免 TUI deferred 路径和非 TUI awaited 路径复制 IDE 连接逻辑: +Add a shared helper to avoid duplicating IDE connection logic between the TUI deferred path and the non-TUI awaited path: ```ts export async function connectIdeForStartup(config: Config): Promise { @@ -178,7 +178,7 @@ export async function connectIdeForStartup(config: Config): Promise { } ``` -`initializeApp()` 保留为首屏前关键初始化,但增加一个显式选项: +`initializeApp()` remains as pre-first-paint critical initialization, but gains an explicit option: ```ts interface InitializeAppOptions { @@ -186,18 +186,18 @@ interface InitializeAppOptions { } ``` -默认值必须保持兼容:`deferIdeConnection` 默认为 `false`。也就是说,不传选项时仍会在 `initializeApp()` 内 await IDE 连接。 +The default must remain backward-compatible: `deferIdeConnection` defaults to `false`. That is, when no option is passed, IDE connection is still awaited within `initializeApp()`. -`initializeApp()` 的 awaited 内容变为: +The awaited content of `initializeApp()` becomes: - `initializeI18n(...)` - `performInitialAuth(...)` - `validateTheme(settings)` -- 当 `deferIdeConnection !== true` 时,`await connectIdeForStartup(config)` -- 计算 `shouldOpenAuthDialog` -- 读取 `config.getGeminiMdFileCount()` +- When `deferIdeConnection !== true`, `await connectIdeForStartup(config)` +- Compute `shouldOpenAuthDialog` +- Read `config.getGeminiMdFileCount()` -`gemini.tsx` 调用处负责按运行模式选择: +The call site in `gemini.tsx` is responsible for selecting based on the run mode: ```ts const deferIdeConnection = @@ -208,140 +208,140 @@ const initializationResult = await initializeApp(config, settings, { }); ``` -随后只有当 `deferIdeConnection === true` 时,`startInteractiveUI()` 才通过 `startPostRenderPrefetches(..., { connectIde: true })` fire-and-forget 启动 IDE 连接;prompt-interactive 因为会自动提交首个问题,继续在 render 前 await IDE,并传入 `connectIde: false` 避免 post-render 重复连接。 +Subsequently, only when `deferIdeConnection === true`, `startInteractiveUI()` fires-and-forgets the IDE connection via `startPostRenderPrefetches(..., { connectIde: true })`; prompt-interactive, which auto-submits the first question, continues to await IDE before render and passes `connectIde: false` to avoid post-render duplicate connection. -这个分流修正 review 指出的兼容性风险: +This split addresses the compatibility risk flagged in review: -- 普通 interactive TUI:IDE socket/IPC 连接不再阻塞首屏。 -- `qwen -i "prompt"`:继续在首个自动提交请求前 await IDE 连接,且 post-render 不重复连接。 -- `qwen -p` / piped stdin:继续在首个模型请求前 await IDE 连接。 -- stream-json:继续在 session/control 请求处理前完成 IDE 连接。 -- ACP/Zed:继续保留 awaited IDE startup,避免首个请求缺 IDE context/status。 +- Plain interactive TUI: IDE socket/IPC connection no longer blocks first paint. +- `qwen -i "prompt"`: continues to await IDE connection before the first auto-submitted request, and post-render does not reconnect. +- `qwen -p` / piped stdin: continues to await IDE connection before the first model request. +- stream-json: continues to complete IDE connection before session/control request handling. +- ACP/Zed: continues to retain awaited IDE startup, avoiding missing IDE context/status on the first request. -### 5. MCP 与非交互语义保持不变 +### 5. MCP and Non-Interactive Semantics Remain Unchanged -本方案不改 core MCP 状态机。 +This design does not change the core MCP state machine. -interactive: +Interactive: -- 继续由 `AppContainer` 在 mount effect 中调用 `config.initialize()`。 -- `Config.initialize()` 继续启动 background MCP discovery。 -- AppContainer 继续监听 `mcp-client-update` 并以约 16ms 批量调用 `geminiClient.setTools()`。 -- 首屏和输入可用不等待 MCP 全部 settle。 +- Continues to call `config.initialize()` in the mount effect of `AppContainer`. +- `Config.initialize()` continues to launch background MCP discovery. +- AppContainer continues to listen for `mcp-client-update` and batch-call `geminiClient.setTools()` at ~16ms intervals. +- First paint and input availability do not wait for MCP to fully settle. -non-interactive / stream-json / ACP: +Non-interactive / stream-json / ACP: -- 继续在首个模型请求前 await IDE 连接。 -- 继续在首个模型请求前等待 `config.waitForMcpReady()`。 -- 保持旧同步路径的工具可见性语义。 -- 保持 MCP 失败时 stderr warning 的现有行为。 +- Continues to await IDE connection before the first model request. +- Continues to await `config.waitForMcpReady()` before the first model request. +- Preserves the tool visibility semantics of the old synchronous path. +- Preserves the existing behavior of stderr warnings on MCP failure. -## 性能收益预估 +## Estimated Performance Gains -收益分为两类。 +Gains fall into two categories. -第一类是首屏前关键路径缩短: +The first is shortened critical path before first paint: -- 普通 interactive TUI 的 IDE client connection 不再阻塞首屏;收益取决于 IDE socket/IPC 连接耗时,预期为几十毫秒到数百毫秒。 -- 普通 interactive TUI 的 telemetry SDK init 不再阻塞首屏;收益取决于 OTel SDK/exporter 构造成本,通常是小到中等的同步启动开销。 -- update check、housekeeping、preconnect 等任务有统一 fire-and-forget 入口,不会被未来维护误放回 awaited path。 +- IDE client connection for plain interactive TUI no longer blocks first paint; gains depend on IDE socket/IPC connection time, expected to be tens to hundreds of milliseconds. +- Telemetry SDK init for plain interactive TUI no longer blocks first paint; gains depend on OTel SDK/exporter construction cost, typically a small to moderate synchronous startup overhead. +- Update check, housekeeping, preconnect, and similar tasks have a unified fire-and-forget entry point, preventing future maintenance from accidentally placing them back on the awaited path. -第二类是首个 API 请求收益: +The second is first API request gains: -- 继续保留 #3223 的 API preconnect 设计。 -- 当 proxy/shared dispatcher 可复用时,首个 API 请求可减少 TCP+TLS 握手成本,预期 100-200ms。 +- Continues to preserve the #3223 API preconnect design. +- When proxy/shared dispatcher is reusable, the first API request can avoid TCP+TLS handshake costs, expected 100-200ms. -需要注意:#3219 的历史 baseline 显示模块加载曾占启动总耗时约 94%,#3221 lazy tool registration 已经针对最大瓶颈做过优化。#3222 的核心收益更偏 perceived TTI 和首屏响应,而不是消除全部模块加载成本。 +Note: #3219's historical baseline showed module loading once accounted for ~94% of total startup time; #3221's lazy tool registration has already addressed the largest bottleneck. The core benefit of #3222 is more about perceived TTI and first-paint responsiveness, rather than eliminating all module loading costs. -## 风险与影响范围 +## Risks and Scope of Impact -### 风险 +### Risks -- 普通 TUI 的 IDE 能力可能从“首屏前已连接”变为“首屏后很快连接”。缓解方式:只在普通 interactive TUI 路径延后;非交互、stream-json、ACP/Zed 保持首个请求前 awaited 连接。 -- render 前 telemetry event 可能在 SDK 未初始化时被 no-op 丢弃。缓解方式:只延后 interactive TUI;非交互首个请求相关 telemetry 仍保持原语义,不新增缓冲队列。 -- deferred task 失败可能不明显。缓解方式:统一 wrapper 记录 debug log 和 profiler async event。 -- 迁移 update/preconnect 时可能改变原有门控。缓解方式:逐字保留现有 settings/env 条件。 -- 过度 defer 可能导致首个用户输入依赖的能力未就绪。缓解方式:auth、config construction、permissions、hooks、memory、tool registry、non-interactive MCP ready 全部保持 awaited。 +- IDE capabilities on plain TUI may shift from "connected before first paint" to "connected very shortly after first paint". Mitigation: only defer on the plain interactive TUI path; non-interactive, stream-json, and ACP/Zed maintain awaited connection before the first request. +- Pre-render telemetry events may be no-op dropped when the SDK is not yet initialized. Mitigation: only defer for interactive TUI; non-interactive pre-first-request telemetry retains its original semantics, no new buffering queue added. +- Deferred task failures may not be prominent. Mitigation: unified wrapper records debug logs and profiler async events. +- Migrating update/preconnect may inadvertently change existing gates. Mitigation: verbatim preservation of existing settings/env conditions. +- Over-deferring may leave capabilities unready when the first user input depends on them. Mitigation: auth, config construction, permissions, hooks, memory, tool registry, and non-interactive MCP ready all remain awaited. -### 影响范围 +### Scope of Impact -预计只涉及 CLI 启动层: +Expected to only involve the CLI startup layer: - `packages/cli/src/startup/startup-prefetch.ts` - `packages/cli/src/core/initializer.ts` - `packages/cli/src/gemini.tsx` - `packages/cli/src/ui/startInteractiveUI.tsx` -- 对应单元测试 +- Corresponding unit tests -不改动: +No changes to: -- CLI 参数与配置 schema -- core tool registry 协议 -- MCP discovery 状态机 -- 模型请求协议 -- 用户可见命令行为 +- CLI arguments and configuration schema +- Core tool registry protocol +- MCP discovery state machine +- Model request protocol +- User-visible command behavior -## 单元测试计划 +## Unit Test Plan ### `packages/cli/src/startup/startup-prefetch.test.ts` -覆盖: +Coverage: -- `startEarlyStartupPrefetches()` 会调用 `preconnectApi()`,并传入 auth type、resolved base URL、proxy。 -- early prefetch 不 await 任务完成。 -- repeated call 幂等,不重复启动同一 early task。 -- `startPostRenderPrefetches()` 在 `enableAutoUpdate !== false` 时启动 update check。 -- `enableAutoUpdate === false` 时不启动 update check。 -- `options.connectIde === true` 且 `config.getIdeMode() === true` 时启动 IDE connect 并调用 `logIdeConnection()`。 -- `options.connectIde !== true` 时不触发 IDE connect。 -- `config.getIdeMode() === false` 时即使 `options.connectIde === true` 也不触发 IDE connect。 -- `options.initializeTelemetry === true` 时启动 telemetry SDK init。 -- `options.initializeTelemetry !== true` 时不触发 telemetry SDK init。 -- deferred task reject 不会让 public API throw,只写 debug log。 +- `startEarlyStartupPrefetches()` calls `preconnectApi()` with auth type, resolved base URL, and proxy. +- Early prefetch does not await task completion. +- Repeated calls are idempotent, not launching the same early task again. +- `startPostRenderPrefetches()` launches update check when `enableAutoUpdate !== false`. +- Does not launch update check when `enableAutoUpdate === false`. +- Launches IDE connect and calls `logIdeConnection()` when `options.connectIde === true` and `config.getIdeMode() === true`. +- Does not trigger IDE connect when `options.connectIde !== true`. +- Does not trigger IDE connect when `config.getIdeMode() === false` even if `options.connectIde === true`. +- Launches telemetry SDK init when `options.initializeTelemetry === true`. +- Does not trigger telemetry SDK init when `options.initializeTelemetry !== true`. +- Deferred task rejections do not cause the public API to throw, only write debug logs. ### `packages/cli/src/core/initializer.test.ts` -调整并新增: +Adjustments and additions: -- `initializeApp()` 默认会 await `connectIdeForStartup()`,保持非 TUI 路径兼容。 -- `initializeApp(..., { deferIdeConnection: true })` 不调用 `IdeClient.getInstance()` 或 `connect()`。 -- `initializeApp(..., { deferIdeConnection: false })` 在 `config.getIdeMode() === true` 时调用并 await IDE connect。 -- 仍会 await `initializeI18n()`。 -- 仍会 await `performInitialAuth()`。 -- auth 失败时保留 `authError` 和 `shouldOpenAuthDialog === true`。 -- theme 校验失败时保留 `themeError`。 -- auth type 显式提供且 auth 成功时 `shouldOpenAuthDialog === false`。 +- `initializeApp()` by default awaits `connectIdeForStartup()`, preserving non-TUI path compatibility. +- `initializeApp(..., { deferIdeConnection: true })` does not call `IdeClient.getInstance()` or `connect()`. +- `initializeApp(..., { deferIdeConnection: false })` calls and awaits IDE connect when `config.getIdeMode() === true`. +- Still awaits `initializeI18n()`. +- Still awaits `performInitialAuth()`. +- On auth failure, retains `authError` and `shouldOpenAuthDialog === true`. +- On theme validation failure, retains `themeError`. +- When auth type is explicitly provided and auth succeeds, `shouldOpenAuthDialog === false`. ### `packages/cli/src/ui/startInteractiveUI.test.tsx` -覆盖: +Coverage: -- Ink `render()` 返回并记录 `first_paint` 后,调用 `startPostRenderPrefetches(config, settings)`。 -- 普通 TUI 路径传入 `{ connectIde: true, initializeTelemetry: true }`。 -- 当 prompt-interactive 已在 render 前 await IDE 时,传入 `{ connectIde: false, initializeTelemetry: true }`,避免重复 IDE connect。 -- 非 TUI 路径不通过 `startInteractiveUI()` 触发 IDE/telemetry post-render prefetch。 -- post-render prefetch reject 不会导致 `startInteractiveUI()` reject。 -- update check 从 `startInteractiveUI()` 内联逻辑移走后,不再被直接调用。 +- After Ink `render()` returns and `first_paint` is recorded, calls `startPostRenderPrefetches(config, settings)`. +- Plain TUI path passes `{ connectIde: true, initializeTelemetry: true }`. +- When prompt-interactive has already awaited IDE before render, passes `{ connectIde: false, initializeTelemetry: true }` to avoid duplicate IDE connect. +- Non-TUI paths do not trigger IDE/telemetry post-render prefetch through `startInteractiveUI()`. +- Post-render prefetch rejections do not cause `startInteractiveUI()` to reject. +- After update check is moved out of `startInteractiveUI()` inline logic, it is no longer directly called. ### `packages/cli/src/gemini.test.tsx` -调整并新增: +Adjustments and additions: -- 普通 interactive TUI 调用 `initializeApp(config, settings, { deferIdeConnection: true })`,并在 post-render prefetch 中连接 IDE。 -- prompt-interactive 调用 `initializeApp(config, settings, { deferIdeConnection: false })`,并且 post-render prefetch 不再连接 IDE。 -- `qwen -p` / piped stdin / stream-json 调用 `initializeApp(config, settings, { deferIdeConnection: false })` 或使用默认值,确保首个请求前 IDE 已连接。 -- ACP/Zed 路径不启用 IDE deferred prefetch,继续走 awaited IDE startup。 +- Plain interactive TUI calls `initializeApp(config, settings, { deferIdeConnection: true })`, and connects IDE in post-render prefetch. +- Prompt-interactive calls `initializeApp(config, settings, { deferIdeConnection: false })`, and post-render prefetch does not reconnect IDE. +- `qwen -p` / piped stdin / stream-json calls `initializeApp(config, settings, { deferIdeConnection: false })` or uses defaults, ensuring IDE is connected before the first request. +- ACP/Zed path does not enable IDE deferred prefetch, continues through awaited IDE startup. ### `packages/core/src/config/config.test.ts` -覆盖: +Coverage: -- telemetry enabled 且未传 `deferTelemetryInitialization` 时,`Config` 构造期仍调用 `initializeTelemetry(config)`。 -- telemetry enabled 且 `deferTelemetryInitialization === true` 时,`Config` 构造期不调用 `initializeTelemetry(config)`,但 `config.getTelemetryEnabled()` 仍为 true。 +- When telemetry is enabled and `deferTelemetryInitialization` is not passed, `Config` construction still calls `initializeTelemetry(config)`. +- When telemetry is enabled and `deferTelemetryInitialization === true`, `Config` construction does not call `initializeTelemetry(config)`, but `config.getTelemetryEnabled()` still returns true. -### 回归测试 +### Regression Tests -建议执行: +Recommended execution: ```bash cd packages/cli && npx vitest run src/core/initializer.test.ts src/startup/startup-prefetch.test.ts @@ -349,21 +349,21 @@ cd packages/cli && npx vitest run src/gemini.test.tsx cd packages/core && npx vitest run src/config/config.test.ts -t "telemetry" ``` -## 验收标准 +## Acceptance Criteria -- interactive REPL 首屏不等待 IDE connection、telemetry init、update check、housekeeping。 -- non-interactive、stream-json、ACP/Zed 在首个请求前仍 await IDE connection。 -- non-interactive、stream-json、ACP/Zed 不延后 telemetry SDK init。 -- API preconnect 仍在 `loadCliConfig()` 后尽早 fire-and-forget。 -- auth、config、permissions、hooks、memory 等关键正确性初始化仍在需要的位置 await。 -- non-interactive 首个 prompt 仍等待 MCP ready。 -- 所有 deferred task 失败都不影响 REPL 渲染。 -- profiler 能看出 deferred task 在 first_paint 前后按预期启动。 -- 单元测试覆盖关键路径、幂等、错误吞噬和非交互兼容约束。 +- Interactive REPL first paint does not wait for IDE connection, telemetry init, update check, or housekeeping. +- Non-interactive, stream-json, and ACP/Zed still await IDE connection before the first request. +- Non-interactive, stream-json, and ACP/Zed do not defer telemetry SDK init. +- API preconnect still fires-and-forgets as early as possible after `loadCliConfig()`. +- Auth, config, permissions, hooks, memory, and other correctness-critical initializations remain awaited where needed. +- Non-interactive first prompt still waits for MCP ready. +- All deferred task failures do not affect REPL rendering. +- Profiler shows deferred tasks launching as expected around first_paint. +- Unit tests cover critical paths, idempotency, error swallowing, and non-interactive compatibility constraints. -## 默认假设 +## Default Assumptions -- #3221 在 GitHub 上实际是 issue,不是 PR;当前仓库已经包含 lazy tool registry 实现。 -- 本方案不新增配置项,避免把启动优化变成用户可配置复杂度。 -- “REPL renders before deferred operations complete” 指 Ink 首屏返回和输入可用,不要求所有后台能力在用户看到 UI 前完成。 -- 非交互模式优先兼容性,不追求和 interactive 一样激进的首屏优化。 +- #3221 is actually an issue on GitHub, not a PR; the current repository already contains the lazy tool registry implementation. +- This design adds no new configuration options, avoiding turning startup optimization into user-configurable complexity. +- "REPL renders before deferred operations complete" means Ink first-paint return and input availability, not requiring all background capabilities to finish before the user sees the UI. +- Non-interactive mode prioritizes compatibility, not pursuing first-paint optimization as aggressively as interactive mode. From 006935bb00e9070bde2b78ab1f378bbd189b8092 Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Sun, 5 Jul 2026 09:51:35 +0800 Subject: [PATCH 07/11] fix(cli): address startup prefetch review comments Tighten the startup prefetch follow-up fixes from review while keeping prompt-interactive telemetry on the fast interactive startup path. - Preserve Error objects when deferred startup tasks fail - Remove the unbalanced api_preconnect profiler lifecycle event - Guard background housekeeping so it only runs for interactive configs - Document and test prompt-interactive telemetry deferral semantics --- packages/cli/src/config/config.test.ts | 28 +++++++++++++++ packages/cli/src/config/config.ts | 4 +++ .../cli/src/startup/startup-prefetch.test.ts | 36 +++++++++++++++---- packages/cli/src/startup/startup-prefetch.ts | 19 +++++----- 4 files changed, 71 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 19c090eddee..e8d01ba623d 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -31,6 +31,7 @@ const mockSessionServiceInstance = vi.hoisted(() => ({ const mockSessionServiceCtor = vi.hoisted(() => vi.fn(() => mockSessionServiceInstance), ); +const mockConfigConstructorParams = vi.hoisted(() => vi.fn()); vi.mock('../utils/stdioHelpers.js', () => ({ writeStderrLine: mockWriteStderrLine, @@ -160,8 +161,15 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { SkillManagerMock.prototype.listSkills = vi.fn().mockResolvedValue([]); SkillManagerMock.prototype.addChangeListener = vi.fn(); SkillManagerMock.prototype.removeChangeListener = vi.fn(); + class ConfigWithParamCapture extends actualServer.Config { + constructor(...args: ConstructorParameters) { + mockConfigConstructorParams(args[0]); + super(...args); + } + } return { ...actualServer, + Config: ConfigWithParamCapture, NativeLspService: vi .fn() .mockImplementation(() => createNativeLspServiceInstance()), @@ -1477,6 +1485,26 @@ describe('loadCliConfig telemetry', () => { expect(config.getTelemetryEnabled()).toBe(true); }); + it('should defer telemetry for prompt-interactive startup', async () => { + process.argv = [ + 'node', + 'script.js', + '-i', + 'hello from prompt-interactive', + '--telemetry', + ]; + const argv = await parseArguments(); + + await loadCliConfig({}, argv); + + expect(mockConfigConstructorParams).toHaveBeenCalledWith( + expect.objectContaining({ + question: 'hello from prompt-interactive', + deferTelemetryInitialization: true, + }), + ); + }); + it('should set telemetry to false when --no-telemetry flag is present', async () => { process.argv = ['node', 'script.js', '--no-telemetry']; const argv = await parseArguments(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 8d2a777a932..5eeb033c632 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2011,6 +2011,10 @@ export async function loadCliConfig( showResponseTokensPerSecond: settings.ui?.showResponseTokensPerSecond === true, telemetry: telemetrySettings, + // Prompt-interactive (`qwen -i "prompt"`) intentionally follows the + // interactive telemetry trade-off: telemetry may start after the + // auto-submitted first prompt, because it is observational and does not + // affect first-request correctness like IDE context does. deferTelemetryInitialization: interactive && !isAcpMode, outboundCorrelation: settings.outboundCorrelation, usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index e2acd673e0e..23ee395085a 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -65,6 +65,7 @@ function makeConfig(overrides: Partial = {}): Config { getProxy: () => 'http://proxy.example', getProjectRoot: () => '/repo', getIdeMode: () => true, + isInteractive: () => true, ...overrides, } as unknown as Config; } @@ -97,6 +98,17 @@ describe('startupPrefetch', () => { }); }); + it('does not record an unbalanced lifecycle event for API preconnect', () => { + const config = makeConfig(); + + startEarlyStartupPrefetches(config); + + expect(mockRecordStartupEvent).not.toHaveBeenCalledWith( + 'startup_prefetch_started', + { name: 'api_preconnect' }, + ); + }); + it('starts early prefetch only once per config', () => { const config = makeConfig(); @@ -192,8 +204,9 @@ describe('startupPrefetch', () => { it('swallows telemetry initialization failures', async () => { const config = makeConfig(); + const error = new Error('otel unavailable'); mockInitializeTelemetry.mockImplementation(() => { - throw new Error('otel unavailable'); + throw error; }); expect(() => @@ -204,14 +217,13 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); - expect(mockWarn).toHaveBeenCalledWith( - 'telemetry_init failed: otel unavailable', - ); + expect(mockWarn).toHaveBeenCalledWith('telemetry_init failed:', error); }); it('swallows deferred task failures', async () => { const config = makeConfig(); - mockCheckForUpdates.mockRejectedValue(new Error('network down')); + const error = new Error('network down'); + mockCheckForUpdates.mockRejectedValue(error); expect(() => startPostRenderPrefetches(config, makeSettings()), @@ -219,7 +231,19 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); - expect(mockWarn).toHaveBeenCalledWith('update_check failed: network down'); + expect(mockWarn).toHaveBeenCalledWith('update_check failed:', error); + }); + + it('does not start housekeeping for non-interactive configs', async () => { + const config = makeConfig({ + isInteractive: () => false, + } as Partial); + + startPostRenderPrefetches(config, makeSettings()); + + await vi.dynamicImportSettled(); + + expect(mockStartBackgroundHousekeeping).not.toHaveBeenCalled(); }); it('starts post-render prefetch only once per config', async () => { diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index 84da1cef143..4e5a4628ae3 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -32,9 +32,7 @@ function runDeferredTask(name: string, task: () => Promise | void): void { }) .catch((err) => { recordStartupEvent('startup_prefetch_failed', { name }); - debugLogger.warn( - `${name} failed: ${err instanceof Error ? err.message : String(err)}`, - ); + debugLogger.warn(`${name} failed:`, err); }); } @@ -55,7 +53,6 @@ export function startEarlyStartupPrefetches(config: Config): void { const resolvedBaseUrl = modelsConfig.getGenerationConfig().baseUrl; const proxy = config.getProxy(); preconnectApi(authType, { resolvedBaseUrl, proxy }); - recordStartupEvent('startup_prefetch_started', { name: 'api_preconnect' }); } catch (error) { debugLogger.debug( `Preconnect skipped due to error getting authType: ${error}`, @@ -103,10 +100,12 @@ export function startPostRenderPrefetches( }); } - runDeferredTask('background_housekeeping', async () => { - const { startBackgroundHousekeeping } = await import( - '../utils/housekeeping/scheduler.js' - ); - startBackgroundHousekeeping(config, settings); - }); + if (config.isInteractive()) { + runDeferredTask('background_housekeeping', async () => { + const { startBackgroundHousekeeping } = await import( + '../utils/housekeeping/scheduler.js' + ); + startBackgroundHousekeeping(config, settings); + }); + } } From 3257422fa7e5e6578d11d5f3e0a6b4843388b465 Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Sun, 5 Jul 2026 10:31:47 +0800 Subject: [PATCH 08/11] fix(cli): initialize telemetry for prompt-interactive prompts Ensure sessions launched with an initial interactive prompt have telemetry ready before the auto-submitted first request runs. - Exclude prompt-interactive startup from telemetry deferral - Pass a post-render telemetry option through interactive UI startup - Skip duplicate post-render telemetry startup for initial prompts - Update tests to cover the first-prompt telemetry guarantee Note: Plain interactive TUI startup still defers telemetry post-render. --- packages/cli/src/config/config.test.ts | 4 ++-- packages/cli/src/config/config.ts | 8 +++----- packages/cli/src/gemini.test.tsx | 9 ++++++--- packages/cli/src/gemini.tsx | 5 ++++- packages/cli/src/ui/startInteractiveUI.tsx | 3 ++- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index e8d01ba623d..a6f34eefdbf 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1485,7 +1485,7 @@ describe('loadCliConfig telemetry', () => { expect(config.getTelemetryEnabled()).toBe(true); }); - it('should defer telemetry for prompt-interactive startup', async () => { + it('should initialize telemetry before prompt-interactive startup', async () => { process.argv = [ 'node', 'script.js', @@ -1500,7 +1500,7 @@ describe('loadCliConfig telemetry', () => { expect(mockConfigConstructorParams).toHaveBeenCalledWith( expect.objectContaining({ question: 'hello from prompt-interactive', - deferTelemetryInitialization: true, + deferTelemetryInitialization: false, }), ); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 5eeb033c632..b400a02645e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2011,11 +2011,9 @@ export async function loadCliConfig( showResponseTokensPerSecond: settings.ui?.showResponseTokensPerSecond === true, telemetry: telemetrySettings, - // Prompt-interactive (`qwen -i "prompt"`) intentionally follows the - // interactive telemetry trade-off: telemetry may start after the - // auto-submitted first prompt, because it is observational and does not - // affect first-request correctness like IDE context does. - deferTelemetryInitialization: interactive && !isAcpMode, + // Prompt-interactive (`qwen -i "prompt"`) auto-submits its first prompt, + // so telemetry must be ready before that request is sent. + deferTelemetryInitialization: interactive && !isAcpMode && !question, outboundCorrelation: settings.outboundCorrelation, usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, clearContextOnIdle: settings.context?.clearContextOnIdle, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index d83aec5ea10..ad6f95a6963 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1182,7 +1182,7 @@ describe('gemini.tsx main function kitty protocol', () => { expect.any(Object), { connectIde: false, - initializeTelemetry: true, + initializeTelemetry: false, }, ); expect(mockStartEarlyStartupPrefetches).toHaveBeenCalledWith( @@ -1646,13 +1646,16 @@ describe('startInteractiveUI', () => { mockStartupWarnings, mockWorkspaceRoot, mockInitializationResult, - { postRenderConnectIde: false }, + { + postRenderConnectIde: false, + postRenderInitializeTelemetry: false, + }, ); expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( mockConfig, mockSettings, - { connectIde: false, initializeTelemetry: true }, + { connectIde: false, initializeTelemetry: false }, ); }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index d8494625f91..d6db0b62a78 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -842,7 +842,10 @@ export async function main() { startupWarnings, process.cwd(), initializationResult!, - { postRenderConnectIde: deferIdeConnection }, + { + postRenderConnectIde: deferIdeConnection, + postRenderInitializeTelemetry: !input, + }, ); // Clean up corruption env vars so subsequent relaunch children // and subprocesses don't inherit stale state. diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index 88f387d11ba..5ffef3291f5 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -44,6 +44,7 @@ const debugLogger = createDebugLogger('STARTUP'); export interface StartInteractiveUIOptions { postRenderConnectIde?: boolean; + postRenderInitializeTelemetry?: boolean; } export async function startInteractiveUI( @@ -201,7 +202,7 @@ export async function startInteractiveUI( profileCheckpoint('first_paint'); startPostRenderPrefetches(config, settings, { connectIde: options.postRenderConnectIde ?? true, - initializeTelemetry: true, + initializeTelemetry: options.postRenderInitializeTelemetry ?? true, }); registerCleanup(async () => { From 6cba2c5789e99d0712c6ab152df979ca012685ea Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Mon, 6 Jul 2026 11:42:13 +0800 Subject: [PATCH 09/11] fix(cli): preserve startup first-request guarantees Keep deferred startup work from weakening first-request behavior in interactive sessions that submit prompts automatically or remotely. - Store telemetry deferral on Config and reuse that decision at render time - Keep IDE startup awaited for prompt-interactive and input-file sessions - Add a timeout for deferred IDE connection failures - Cover ordinary interactive telemetry deferral and IDE startup edge cases --- packages/cli/src/config/config.test.ts | 17 +++ packages/cli/src/gemini.test.tsx | 142 +++++++++++++++++- packages/cli/src/gemini.tsx | 5 +- .../cli/src/startup/startup-prefetch.test.ts | 30 +++- packages/cli/src/startup/startup-prefetch.ts | 28 +++- packages/cli/src/ui/startInteractiveUI.tsx | 4 +- packages/core/src/config/config.test.ts | 2 + packages/core/src/config/config.ts | 9 +- 8 files changed, 224 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 4b29d46e465..3933796be2d 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1549,6 +1549,7 @@ describe('loadCliConfig', () => { describe('loadCliConfig telemetry', () => { const originalArgv = process.argv; + const originalIsTTY = process.stdin.isTTY; beforeEach(() => { vi.resetAllMocks(); @@ -1558,6 +1559,7 @@ describe('loadCliConfig telemetry', () => { afterEach(() => { process.argv = originalArgv; + process.stdin.isTTY = originalIsTTY; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1598,6 +1600,21 @@ describe('loadCliConfig telemetry', () => { ); }); + it('should defer telemetry for ordinary interactive startup', async () => { + process.argv = ['node', 'script.js', '--telemetry']; + process.stdin.isTTY = true; + const argv = await parseArguments(); + + await loadCliConfig({}, argv); + + expect(mockConfigConstructorParams).toHaveBeenCalledWith( + expect.objectContaining({ + question: '', + deferTelemetryInitialization: true, + }), + ); + }); + it('should set telemetry to false when --no-telemetry flag is present', async () => { process.argv = ['node', 'script.js', '--no-telemetry']; const argv = await parseArguments(); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index affb9ee0cab..c2cfb60f7a4 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1200,6 +1200,7 @@ describe('gemini.tsx main function kitty protocol', () => { getModelsConfig: () => ({ getCurrentAuthType: () => null }), getUsageStatisticsEnabled: () => true, getSessionId: () => 'test-session-id', + isTelemetryInitializationDeferred: () => true, } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ errors: [], @@ -1324,6 +1325,7 @@ describe('gemini.tsx main function kitty protocol', () => { getModelsConfig: () => ({ getCurrentAuthType: () => null }), getUsageStatisticsEnabled: () => true, getSessionId: () => 'test-session-id', + isTelemetryInitializationDeferred: () => false, } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ errors: [], @@ -1388,6 +1390,7 @@ describe('gemini.tsx main function kitty protocol', () => { channel: undefined, chatRecording: undefined, sessionId: undefined, + fallbackModel: undefined, }); await main(); @@ -1412,6 +1415,127 @@ describe('gemini.tsx main function kitty protocol', () => { ); }); + it('should await IDE connection when interactive mode has an input file', async () => { + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + const initializerModule = await import('./core/initializer.js'); + const initializeAppSpy = vi + .spyOn(initializerModule, 'initializeApp') + .mockResolvedValue({ + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }); + vi.mocked(loadCliConfig).mockResolvedValue({ + isInteractive: () => true, + getQuestion: () => '', + getInputFile: () => '/tmp/qwen-input.jsonl', + getSandbox: () => false, + getDebugMode: () => false, + getListExtensions: () => false, + getMcpServers: () => ({}), + getTopTierMcpServers: () => undefined, + initialize: vi.fn(), + waitForMcpReady: vi.fn().mockResolvedValue(undefined), + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, + getScreenReader: () => false, + getGeminiMdFileCount: () => 0, + getWarnings: () => [], + isSafeMode: () => false, + getModelsConfig: () => ({ getCurrentAuthType: () => null }), + getUsageStatisticsEnabled: () => true, + getSessionId: () => 'test-session-id', + isTelemetryInitializationDeferred: () => true, + } as unknown as Config); + vi.mocked(loadSettings).mockReturnValue({ + errors: [], + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + migrationWarnings: [], + getUserHooks: () => undefined, + getProjectHooks: () => undefined, + } as never); + vi.mocked(parseArguments).mockResolvedValue({ + model: undefined, + sandbox: undefined, + sandboxImage: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + systemPrompt: undefined, + appendSystemPrompt: undefined, + query: undefined, + yolo: undefined, + bare: undefined, + approvalMode: undefined, + telemetry: undefined, + telemetryTarget: undefined, + telemetryOtlpEndpoint: undefined, + telemetryOtlpProtocol: undefined, + telemetryLogPrompts: undefined, + telemetryOutfile: undefined, + allowedMcpServerNames: undefined, + mcpConfig: undefined, + allowedTools: undefined, + acp: undefined, + experimentalAcp: undefined, + extensions: undefined, + listExtensions: undefined, + openaiLogging: undefined, + openaiApiKey: undefined, + openaiBaseUrl: undefined, + openaiLoggingDir: undefined, + proxy: undefined, + includeDirectories: undefined, + screenReader: undefined, + inputFormat: undefined, + outputFormat: undefined, + includePartialMessages: undefined, + continue: undefined, + resume: undefined, + coreTools: undefined, + excludeTools: undefined, + disabledSlashCommands: undefined, + authType: undefined, + maxSessionTurns: undefined, + maxWallTime: undefined, + maxToolCalls: undefined, + maxSubagentDepth: undefined, + experimentalLsp: undefined, + channel: undefined, + chatRecording: undefined, + sessionId: undefined, + fallbackModel: undefined, + }); + + await main(); + + expect(initializeAppSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + { + deferIdeConnection: false, + }, + ); + expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + { + connectIde: false, + initializeTelemetry: true, + }, + ); + }); + it('should not defer IDE connection when Zed integration is enabled', async () => { const { loadCliConfig, parseArguments } = await import( './config/config.js' @@ -1509,6 +1633,7 @@ describe('gemini.tsx main function kitty protocol', () => { channel: undefined, chatRecording: undefined, sessionId: undefined, + fallbackModel: undefined, }); // Mock process.exit to throw instead of terminating the process @@ -1587,6 +1712,7 @@ describe('gemini.tsx main function kitty protocol', () => { getProxy: () => undefined, getUsageStatisticsEnabled: () => true, getSessionId: () => 'test-session-id', + isTelemetryInitializationDeferred: () => true, } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ errors: [], @@ -1750,7 +1876,8 @@ describe('startInteractiveUI', () => { const mockConfig = { getProjectRoot: () => '/root', getScreenReader: () => false, - } as Config; + isTelemetryInitializationDeferred: () => true, + } as unknown as Config; const mockSettings = { merged: { ui: { @@ -1855,6 +1982,10 @@ describe('startInteractiveUI', () => { }); it('can skip post-render IDE connection after prompt-interactive awaited it', async () => { + const promptInteractiveConfig = { + ...mockConfig, + isTelemetryInitializationDeferred: () => false, + } as unknown as Config; const mockInitializationResult = { authError: null, themeError: null, @@ -1863,19 +1994,16 @@ describe('startInteractiveUI', () => { }; await startInteractiveUI( - mockConfig, + promptInteractiveConfig, mockSettings, mockStartupWarnings, mockWorkspaceRoot, mockInitializationResult, - { - postRenderConnectIde: false, - postRenderInitializeTelemetry: false, - }, + { postRenderConnectIde: false }, ); expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( - mockConfig, + promptInteractiveConfig, mockSettings, { connectIde: false, initializeTelemetry: false }, ); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index d9a4f29892c..7025d33d0aa 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -752,10 +752,12 @@ export async function main() { // For other modes, initialize normally const { initializeApp } = await import('./core/initializer.js'); let input = config.getQuestion(); + const hasRemoteInput = Boolean(config.getInputFile?.()); const deferIdeConnection = config.isInteractive() && !config.getExperimentalZedIntegration() && - !input; + !input && + !hasRemoteInput; const initializationResult = await initializeApp(config, settings, { deferIdeConnection, }); @@ -848,7 +850,6 @@ export async function main() { initializationResult!, { postRenderConnectIde: deferIdeConnection, - postRenderInitializeTelemetry: !input, }, ); // Clean up corruption env vars so subsequent relaunch children diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index 23ee395085a..9a3abe9d9bf 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; import { @@ -83,10 +83,15 @@ function makeSettings( describe('startupPrefetch', () => { beforeEach(() => { vi.clearAllMocks(); + vi.useRealTimers(); mockCheckForUpdates.mockResolvedValue(null); mockConnectIdeForStartup.mockResolvedValue(undefined); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('starts API preconnect with resolved auth config', () => { const config = makeConfig(); @@ -180,6 +185,29 @@ describe('startupPrefetch', () => { expect(mockConnectIdeForStartup).not.toHaveBeenCalled(); }); + it('fails deferred IDE connection when the startup connect hangs', async () => { + vi.useFakeTimers(); + const config = makeConfig(); + const hangingConnect = new Promise(() => {}); + mockConnectIdeForStartup.mockReturnValue(hangingConnect); + + startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); + + await vi.dynamicImportSettled(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(mockRecordStartupEvent).toHaveBeenCalledWith( + 'startup_prefetch_failed', + { name: 'ide_connect' }, + ); + expect(mockWarn).toHaveBeenCalledWith( + 'ide_connect failed:', + expect.objectContaining({ + message: 'ide_connect timed out after 10000ms', + }), + ); + }); + it('initializes telemetry when requested', async () => { const config = makeConfig(); diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index 4e5a4628ae3..61bf7c3adbb 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -15,9 +15,31 @@ import { recordStartupEvent } from '../utils/startupProfiler.js'; const debugLogger = createDebugLogger('STARTUP_PREFETCH'); +const DEFERRED_IDE_CONNECT_TIMEOUT_MS = 10_000; + const earlyStarted = new WeakSet(); const postRenderStarted = new WeakSet(); +function withTimeout( + promise: Promise, + name: string, + timeoutMs: number, +): Promise { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`${name} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + timeoutId.unref?.(); + }); + + return Promise.race([promise, timeout]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }); +} + /** * Starts a best-effort startup task without adding it to the caller's * critical path. Failures are recorded for diagnostics and otherwise @@ -90,7 +112,11 @@ export function startPostRenderPrefetches( if (options.connectIde && config.getIdeMode()) { runDeferredTask('ide_connect', async () => { const { connectIdeForStartup } = await import('../core/initializer.js'); - await connectIdeForStartup(config); + await withTimeout( + connectIdeForStartup(config), + 'ide_connect', + DEFERRED_IDE_CONNECT_TIMEOUT_MS, + ); }); } diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index 5ffef3291f5..a58dd074124 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -202,7 +202,9 @@ export async function startInteractiveUI( profileCheckpoint('first_paint'); startPostRenderPrefetches(config, settings, { connectIde: options.postRenderConnectIde ?? true, - initializeTelemetry: options.postRenderInitializeTelemetry ?? true, + initializeTelemetry: + options.postRenderInitializeTelemetry ?? + config.isTelemetryInitializationDeferred(), }); registerCleanup(async () => { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 51c672e19c0..61f5968bded 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -4052,6 +4052,7 @@ describe('Server Config (config.ts)', () => { }; const config = new Config(paramsWithTelemetry); expect(config.getTelemetryEnabled()).toBe(true); + expect(config.isTelemetryInitializationDeferred()).toBe(false); expect(initializeTelemetry).toHaveBeenCalledWith(config); }); @@ -4064,6 +4065,7 @@ describe('Server Config (config.ts)', () => { const config = new Config(paramsWithTelemetry); expect(config.getTelemetryEnabled()).toBe(true); + expect(config.isTelemetryInitializationDeferred()).toBe(true); expect(initializeTelemetry).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index fadac1e08bc..3d29289511c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1566,6 +1566,7 @@ export class Config { private readonly accessibility: AccessibilitySettings; private readonly showResponseTokensPerSecond: boolean; private readonly telemetrySettings: ResolvedTelemetrySettings; + private readonly telemetryInitializationDeferred: boolean; private readonly outboundCorrelationSettings: OutboundCorrelationSettings; private readonly gitCoAuthor: GitCoAuthorSettings; private readonly usageStatisticsEnabled: boolean; @@ -1801,6 +1802,8 @@ export class Config { metrics: params.telemetry?.metrics, resourceAttributeWarnings: params.telemetry?.resourceAttributeWarnings, }; + this.telemetryInitializationDeferred = + params.deferTelemetryInitialization ?? false; this.outboundCorrelationSettings = { propagateTraceContext: params.outboundCorrelation?.propagateTraceContext ?? false, @@ -1959,7 +1962,7 @@ export class Config { if ( this.telemetrySettings.enabled && - !params.deferTelemetryInitialization + !this.telemetryInitializationDeferred ) { initializeTelemetry(this); } @@ -4964,6 +4967,10 @@ export class Config { return this.telemetrySettings.enabled ?? false; } + isTelemetryInitializationDeferred(): boolean { + return this.telemetryInitializationDeferred; + } + getTelemetryLogPromptsEnabled(): boolean { return this.telemetrySettings.logPrompts ?? true; } From d05359056a1db696c4bc37b1e925a90c55c64f3b Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Mon, 6 Jul 2026 14:17:10 +0800 Subject: [PATCH 10/11] fix(cli): make post-render IDE connection opt-in Default startInteractiveUI to the already-connected IDE path so future callers do not accidentally connect twice when initializeApp used its eager default. - Change the post-render IDE connection default to false - Update startInteractiveUI tests to assert the safer default --- packages/cli/src/gemini.test.tsx | 4 ++-- packages/cli/src/ui/startInteractiveUI.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index c2cfb60f7a4..c778bafec65 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1977,7 +1977,7 @@ describe('startInteractiveUI', () => { expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( mockConfig, mockSettings, - { connectIde: true, initializeTelemetry: true }, + { connectIde: false, initializeTelemetry: true }, ); }); @@ -2039,7 +2039,7 @@ describe('startInteractiveUI', () => { expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( mockConfig, settingsWithAutoUpdateDisabled, - { connectIde: true, initializeTelemetry: true }, + { connectIde: false, initializeTelemetry: true }, ); }); }); diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index a58dd074124..9c8a1021a77 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -201,7 +201,7 @@ export async function startInteractiveUI( // `input_enabled` checkpoints that complete the first-screen picture. profileCheckpoint('first_paint'); startPostRenderPrefetches(config, settings, { - connectIde: options.postRenderConnectIde ?? true, + connectIde: options.postRenderConnectIde ?? false, initializeTelemetry: options.postRenderInitializeTelemetry ?? config.isTelemetryInitializationDeferred(), From 95c5f6c033a11730292eadfdfec4e5f66e3a78ef Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Tue, 7 Jul 2026 17:25:10 +0800 Subject: [PATCH 11/11] perf(cli): surface deferred IDE connection status Make ordinary interactive IDE startup visible while preserving the post-render prefetch path and first-paint performance tradeoff. - Emit deferred IDE connection lifecycle events for connecting, success, and failure states - Surface IDE startup status in the TUI footer without blocking input - Log late underlying IDE failures after timeout for better diagnostics - Document telemetry deferral tradeoffs and add startup lifecycle tests --- packages/cli/src/config/config.ts | 7 +- packages/cli/src/core/initializer.test.ts | 10 ++ .../cli/src/startup/startup-prefetch.test.ts | 136 ++++++++++++++++-- packages/cli/src/startup/startup-prefetch.ts | 35 ++++- packages/cli/src/ui/AppContainer.tsx | 20 ++- .../cli/src/ui/components/Footer.test.tsx | 43 ++++++ packages/cli/src/ui/components/Footer.tsx | 10 ++ .../cli/src/ui/contexts/UIStateContext.tsx | 2 + packages/cli/src/utils/events.ts | 7 + 9 files changed, 251 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 5a6b61c5fb9..e118aa5cfa0 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2050,8 +2050,11 @@ export async function loadCliConfig( showResponseTokensPerSecond: settings.ui?.showResponseTokensPerSecond === true, telemetry: telemetrySettings, - // Prompt-interactive (`qwen -i "prompt"`) auto-submits its first prompt, - // so telemetry must be ready before that request is sent. + // Ordinary interactive TUI defers telemetry until after first paint. Auth + // events emitted before the deferred init are an accepted startup-latency + // tradeoff. This intentionally differs from IDE deferral: `qwen -i + // "prompt"` must await IDE context before auto-submit, but telemetry can + // still initialize after render unless an initial prompt is present. deferTelemetryInitialization: interactive && !isAcpMode && !question, outboundCorrelation: settings.outboundCorrelation, usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index 0ac9e5c5110..f57a1d8f6e2 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -196,6 +196,16 @@ describe('initializeApp', () => { expect(mockLogIdeConnection).toHaveBeenCalled(); }); + it('should not connect to IDE through startup helper when not in IDE mode', async () => { + mockConfig.getIdeMode.mockReturnValue(false); + + await connectIdeForStartup(mockConfig as never); + + expect(mockGetInstance).not.toHaveBeenCalled(); + expect(mockConnect).not.toHaveBeenCalled(); + expect(mockLogIdeConnection).not.toHaveBeenCalled(); + }); + it('should not connect to IDE when not in IDE mode', async () => { mockConfig.getIdeMode.mockReturnValue(false); diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index 9a3abe9d9bf..6a853d8a849 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -7,6 +7,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; +import { + AppEvent, + appEvents, + type StartupIdeConnectionStatus, +} from '../utils/events.js'; import { startEarlyStartupPrefetches, startPostRenderPrefetches, @@ -92,6 +97,20 @@ describe('startupPrefetch', () => { vi.useRealTimers(); }); + function captureIdeConnectionStatuses() { + const statuses: StartupIdeConnectionStatus[] = []; + const listener = (status: StartupIdeConnectionStatus) => { + statuses.push(status); + }; + appEvents.on(AppEvent.StartupIdeConnectionStatusChanged, listener); + return { + statuses, + stop: () => { + appEvents.off(AppEvent.StartupIdeConnectionStatusChanged, listener); + }, + }; + } + it('starts API preconnect with resolved auth config', () => { const config = makeConfig(); @@ -135,6 +154,31 @@ describe('startupPrefetch', () => { expect(mockDebug).toHaveBeenCalled(); }); + it('records completed profiler lifecycle events for successful deferred tasks', async () => { + const config = makeConfig({ + isInteractive: () => false, + } as Partial); + + startPostRenderPrefetches(config, makeSettings()); + + await vi.dynamicImportSettled(); + + expect(mockCheckForUpdates).toHaveBeenCalledTimes(1); + expect(mockHandleAutoUpdate).toHaveBeenCalledWith( + null, + expect.any(Object), + '/repo', + ); + expect(mockRecordStartupEvent).toHaveBeenCalledWith( + 'startup_prefetch_started', + { name: 'update_check' }, + ); + expect(mockRecordStartupEvent).toHaveBeenCalledWith( + 'startup_prefetch_completed', + { name: 'update_check' }, + ); + }); + it('starts post-render tasks without awaiting completion', async () => { const config = makeConfig(); const updatePromise = new Promise(() => {}); @@ -167,22 +211,52 @@ describe('startupPrefetch', () => { it('requires connectIde option before connecting IDE', async () => { const config = makeConfig(); + const { statuses, stop } = captureIdeConnectionStatuses(); - startPostRenderPrefetches(config, makeSettings()); + try { + startPostRenderPrefetches(config, makeSettings()); - await vi.dynamicImportSettled(); + await vi.dynamicImportSettled(); - expect(mockConnectIdeForStartup).not.toHaveBeenCalled(); + expect(mockConnectIdeForStartup).not.toHaveBeenCalled(); + expect(statuses).toEqual([]); + } finally { + stop(); + } }); it('does not connect IDE when IDE mode is disabled', async () => { const config = makeConfig({ getIdeMode: () => false } as Partial); + const { statuses, stop } = captureIdeConnectionStatuses(); - startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); + try { + startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); - await vi.dynamicImportSettled(); + await vi.dynamicImportSettled(); + + expect(mockConnectIdeForStartup).not.toHaveBeenCalled(); + expect(statuses).toEqual([]); + } finally { + stop(); + } + }); + + it('emits IDE connecting and connected statuses for deferred IDE startup', async () => { + const config = makeConfig(); + const { statuses, stop } = captureIdeConnectionStatuses(); + + try { + startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); + + await vi.dynamicImportSettled(); - expect(mockConnectIdeForStartup).not.toHaveBeenCalled(); + expect(statuses).toEqual([ + { state: 'connecting' }, + { state: 'connected' }, + ]); + } finally { + stop(); + } }); it('fails deferred IDE connection when the startup connect hangs', async () => { @@ -190,22 +264,64 @@ describe('startupPrefetch', () => { const config = makeConfig(); const hangingConnect = new Promise(() => {}); mockConnectIdeForStartup.mockReturnValue(hangingConnect); + const { statuses, stop } = captureIdeConnectionStatuses(); + + try { + startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); + + await vi.dynamicImportSettled(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(statuses).toEqual([ + { state: 'connecting' }, + { + state: 'failed', + message: 'ide_connect timed out after 10000ms', + }, + ]); + expect(mockRecordStartupEvent).toHaveBeenCalledWith( + 'startup_prefetch_failed', + { name: 'ide_connect' }, + ); + expect(mockWarn).toHaveBeenCalledWith( + 'ide_connect failed:', + expect.objectContaining({ + message: 'ide_connect timed out after 10000ms', + }), + ); + } finally { + stop(); + } + }); + + it('logs the underlying IDE connection error after timeout', async () => { + vi.useFakeTimers(); + const config = makeConfig(); + let rejectConnect!: (error: Error) => void; + const delayedFailure = new Promise((_, reject) => { + rejectConnect = reject; + }); + mockConnectIdeForStartup.mockReturnValue(delayedFailure); startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); await vi.dynamicImportSettled(); await vi.advanceTimersByTimeAsync(10_000); - expect(mockRecordStartupEvent).toHaveBeenCalledWith( - 'startup_prefetch_failed', - { name: 'ide_connect' }, - ); + const underlyingError = new Error('socket closed'); + rejectConnect(underlyingError); + await vi.dynamicImportSettled(); + expect(mockWarn).toHaveBeenCalledWith( 'ide_connect failed:', expect.objectContaining({ message: 'ide_connect timed out after 10000ms', }), ); + expect(mockDebug).toHaveBeenCalledWith( + 'ide_connect underlying error after timeout:', + underlyingError, + ); }); it('initializes telemetry when requested', async () => { diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index 61bf7c3adbb..8f07cf81925 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -11,6 +11,7 @@ import { } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; import { preconnectApi } from '../utils/apiPreconnect.js'; +import { AppEvent, appEvents } from '../utils/events.js'; import { recordStartupEvent } from '../utils/startupProfiler.js'; const debugLogger = createDebugLogger('STARTUP_PREFETCH'); @@ -26,13 +27,21 @@ function withTimeout( timeoutMs: number, ): Promise { let timeoutId: ReturnType | undefined; + let timedOut = false; const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => { + timedOut = true; reject(new Error(`${name} timed out after ${timeoutMs}ms`)); }, timeoutMs); timeoutId.unref?.(); }); + promise.catch((err) => { + if (timedOut) { + debugLogger.debug(`${name} underlying error after timeout:`, err); + } + }); + return Promise.race([promise, timeout]).finally(() => { if (timeoutId) { clearTimeout(timeoutId); @@ -111,12 +120,26 @@ export function startPostRenderPrefetches( if (options.connectIde && config.getIdeMode()) { runDeferredTask('ide_connect', async () => { - const { connectIdeForStartup } = await import('../core/initializer.js'); - await withTimeout( - connectIdeForStartup(config), - 'ide_connect', - DEFERRED_IDE_CONNECT_TIMEOUT_MS, - ); + appEvents.emit(AppEvent.StartupIdeConnectionStatusChanged, { + state: 'connecting', + }); + try { + const { connectIdeForStartup } = await import('../core/initializer.js'); + await withTimeout( + connectIdeForStartup(config), + 'ide_connect', + DEFERRED_IDE_CONNECT_TIMEOUT_MS, + ); + appEvents.emit(AppEvent.StartupIdeConnectionStatusChanged, { + state: 'connected', + }); + } catch (err) { + appEvents.emit(AppEvent.StartupIdeConnectionStatusChanged, { + state: 'failed', + message: err instanceof Error ? err.message : String(err), + }); + throw err; + } }); } diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4aa6bd83122..3995ca3dc52 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -87,7 +87,11 @@ import { profileCheckpoint, finalizeStartupProfile, } from '../utils/startupProfiler.js'; -import { appEvents } from '../utils/events.js'; +import { + AppEvent, + appEvents, + type StartupIdeConnectionStatus, +} from '../utils/events.js'; import process from 'node:process'; /** @@ -2580,6 +2584,8 @@ export const AppContainer = (props: AppContainerProps) => { const [idePromptAnswered, setIdePromptAnswered] = useState(false); const [currentIDE, setCurrentIDE] = useState(null); + const [startupIdeConnectionStatus, setStartupIdeConnectionStatus] = + useState({ state: 'idle' }); useEffect(() => { const getIde = async () => { @@ -2831,6 +2837,16 @@ export const AppContainer = (props: AppContainerProps) => { return unsubscribe; }, []); + useEffect(() => { + const listener = (status: StartupIdeConnectionStatus) => { + setStartupIdeConnectionStatus(status); + }; + appEvents.on(AppEvent.StartupIdeConnectionStatusChanged, listener); + return () => { + appEvents.off(AppEvent.StartupIdeConnectionStatusChanged, listener); + }; + }, []); + const handleEscapePromptChange = useCallback((showPrompt: boolean) => { setShowEscapePrompt(showPrompt); }, []); @@ -3823,6 +3839,7 @@ export const AppContainer = (props: AppContainerProps) => { terminalHeight, mainControlsRef, currentIDE, + startupIdeConnectionStatus, updateInfo, showIdeRestartPrompt, ideTrustRestartReason, @@ -3962,6 +3979,7 @@ export const AppContainer = (props: AppContainerProps) => { terminalHeight, mainControlsRef, currentIDE, + startupIdeConnectionStatus, updateInfo, showIdeRestartPrompt, ideTrustRestartReason, diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 3846782c1d0..ba71e4527a4 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -82,6 +82,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => contextFileNames: [], showToolDescriptions: false, ideContextState: undefined, + startupIdeConnectionStatus: { state: 'idle' }, isConfigInitialized: true, ...overrides, }) as UIState; @@ -147,6 +148,48 @@ describe('