diff --git a/docs/design/performance/fire-and-forget-startup-prefetch.md b/docs/design/performance/fire-and-forget-startup-prefetch.md new file mode 100644 index 00000000000..496352af48e --- /dev/null +++ b/docs/design/performance/fire-and-forget-startup-prefetch.md @@ -0,0 +1,369 @@ +# Fire-and-Forget Startup Prefetch Optimization Design + +## Background and Goals + +The parent issue #3011 breaks down qwen-code startup optimization into multiple subtasks. The current repository has already landed several foundational capabilities: + +- #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. + +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 + 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] +``` + +Current state assessment: + +- `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 + +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 + subgraph CLI[CLI startup] + G[loadCliConfig] --> EP[startEarlyStartupPrefetches] + EP --> PC[API preconnect] + 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 --> OTEL[telemetry SDK init for interactive TUI] + SP --> HK[background housekeeping import] + 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 +``` + +The interactive startup sequence under the new design: + +```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 without initial prompt) + Main->>UI: startInteractiveUI(...) + UI->>UI: render() + 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 + MCP-->>App: mcp-client-update batches + App-->>MCP: geminiClient.setTools() +``` + +## Design Changes + +### 1. New Unified Startup Prefetch Scheduler + +Add `packages/cli/src/startup/startup-prefetch.ts`, providing two entry points: + +```ts +startEarlyStartupPrefetches(config: Config): void; +startPostRenderPrefetches( + config: Config, + settings: LoadedSettings, + options?: { connectIde?: boolean; initializeTelemetry?: boolean }, +): void; +``` + +The scheduler does exactly three things: + +- 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. + +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: Maximize Head Start + +`startEarlyStartupPrefetches(config)` is called immediately after `loadCliConfig()` succeeds. + +The first phase includes only API preconnect: + +- 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. + +This adds no new configuration options. Preconnect failures only write debug logs and do not affect startup. + +### 3. Post-Render Prefetch: Launch After First Paint + +`startPostRenderPrefetches(config, settings)` is called in `startInteractiveUI()` after Ink `render()` returns and `first_paint` is recorded. + +First batch includes: + +- 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. + +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. Split `initializeApp()` Critical Path, Preserve Non-TUI Awaited IDE Connection + +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 { + if (!config.getIdeMode()) return; + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); +} +``` + +`initializeApp()` remains as pre-first-paint critical initialization, but gains an explicit option: + +```ts +interface InitializeAppOptions { + deferIdeConnection?: boolean; +} +``` + +The default must remain backward-compatible: `deferIdeConnection` defaults to `false`. That is, when no option is passed, IDE connection is still awaited within `initializeApp()`. + +The awaited content of `initializeApp()` becomes: + +- `initializeI18n(...)` +- `performInitialAuth(...)` +- `validateTheme(settings)` +- When `deferIdeConnection !== true`, `await connectIdeForStartup(config)` +- Compute `shouldOpenAuthDialog` +- Read `config.getGeminiMdFileCount()` + +The call site in `gemini.tsx` is responsible for selecting based on the run mode: + +```ts +const deferIdeConnection = + config.isInteractive() && !config.getExperimentalZedIntegration() && !input; + +const initializationResult = await initializeApp(config, settings, { + deferIdeConnection, +}); +``` + +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. + +This split addresses the compatibility risk flagged in review: + +- 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 and Non-Interactive Semantics Remain Unchanged + +This design does not change the core MCP state machine. + +Interactive: + +- 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: + +- 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: + +- 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. + +The second is first API request gains: + +- 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. + +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 + +- 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 + +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 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()` 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()` 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: + +- 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: + +- 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: + +- 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 +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 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 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. diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 226fa89e8fc..3933796be2d 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()), @@ -1541,6 +1549,7 @@ describe('loadCliConfig', () => { describe('loadCliConfig telemetry', () => { const originalArgv = process.argv; + const originalIsTTY = process.stdin.isTTY; beforeEach(() => { vi.resetAllMocks(); @@ -1550,6 +1559,7 @@ describe('loadCliConfig telemetry', () => { afterEach(() => { process.argv = originalArgv; + process.stdin.isTTY = originalIsTTY; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1570,6 +1580,41 @@ describe('loadCliConfig telemetry', () => { expect(config.getTelemetryEnabled()).toBe(true); }); + it('should initialize telemetry before 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: false, + }), + ); + }); + + 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/config/config.ts b/packages/cli/src/config/config.ts index 826c0dfc724..5a6b61c5fb9 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2050,6 +2050,9 @@ 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. + deferTelemetryInitialization: interactive && !isAcpMode && !question, outboundCorrelation: settings.outboundCorrelation, usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, clearContextOnIdle: settings.context?.clearContextOnIdle, 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 b91dc34b55d..c778bafec65 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -30,6 +30,9 @@ 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()); +const mockRunAcpAgent = vi.hoisted(() => vi.fn()); const lspConfigWatcherMock = vi.hoisted(() => ({ instances: [] as Array<{ listener?: (event: unknown) => void | Promise; @@ -149,6 +152,17 @@ vi.mock('./core/initializer.js', () => ({ }), })); +vi.mock('./startup/startup-prefetch.js', () => ({ + startEarlyStartupPrefetches: (...args: unknown[]) => + mockStartEarlyStartupPrefetches(...args), + startPostRenderPrefetches: (...args: unknown[]) => + mockStartPostRenderPrefetches(...args), +})); + +vi.mock('./acp-integration/acpAgent.js', () => ({ + runAcpAgent: (...args: unknown[]) => mockRunAcpAgent(...args), +})); + vi.mock('./commands/extensions/list.js', () => ({ handleList: mockHandleListExtensions, })); @@ -755,6 +769,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', () => { @@ -1079,6 +1098,11 @@ describe('gemini.tsx main function', () => { configStub, expect.any(Object), ); + expect(initializerModule.initializeApp).toHaveBeenCalledWith( + configStub, + expect.any(Object), + { deferIdeConnection: false }, + ); expect(runExitCleanupMock).toHaveBeenCalledTimes(1); }); }); @@ -1148,6 +1172,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: () => '', @@ -1167,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: [], @@ -1238,6 +1272,399 @@ 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, + }, + ); + expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + { + connectIde: true, + initializeTelemetry: true, + }, + ); + expect(mockStartEarlyStartupPrefetches).toHaveBeenCalledWith( + expect.any(Object), + ); + }); + + 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', + isTelemetryInitializationDeferred: () => false, + } 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: false, + }, + ); + expect(mockStartEarlyStartupPrefetches).toHaveBeenCalledWith( + expect.any(Object), + ); + }); + + 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' + ); + 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, + fallbackModel: 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 () => { @@ -1285,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: [], @@ -1448,7 +1876,8 @@ describe('startInteractiveUI', () => { const mockConfig = { getProjectRoot: () => '/root', getScreenReader: () => false, - } as Config; + isTelemetryInitializationDeferred: () => true, + } as unknown as Config; const mockSettings = { merged: { ui: { @@ -1470,10 +1899,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(), @@ -1524,7 +1949,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 = { @@ -1550,15 +1974,42 @@ 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: false, initializeTelemetry: true }, + ); }); - it('should not call checkForUpdates when enableAutoUpdate is false', async () => { - const { checkForUpdates } = await import('./ui/utils/updateCheck.js'); + 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, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }; + await startInteractiveUI( + promptInteractiveConfig, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + mockInitializationResult, + { postRenderConnectIde: false }, + ); + + expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith( + promptInteractiveConfig, + mockSettings, + { connectIde: false, initializeTelemetry: false }, + ); + }); + + it('delegates auto-update gating to post-render prefetch', async () => { const settingsWithAutoUpdateDisabled = { merged: { general: { @@ -1585,9 +2036,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: false, initializeTelemetry: true }, + ); }); }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 18e2d1df0fc..7025d33d0aa 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -49,6 +49,7 @@ import { buildStartupWorktreeNotice, type StartupWorktreeContext, } from './startup/worktreeStartup.js'; +import { startEarlyStartupPrefetches } from './startup/startup-prefetch.js'; import { cleanupCheckpoints, registerCleanup, @@ -73,7 +74,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'); @@ -687,20 +687,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; @@ -764,7 +751,16 @@ 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); + let input = config.getQuestion(); + const hasRemoteInput = Boolean(config.getInputFile?.()); + const deferIdeConnection = + config.isInteractive() && + !config.getExperimentalZedIntegration() && + !input && + !hasRemoteInput; + const initializationResult = await initializeApp(config, settings, { + deferIdeConnection, + }); profileCheckpoint('after_initialize_app'); if (config.getExperimentalZedIntegration()) { @@ -775,26 +771,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([ ...(config.isSafeMode() @@ -872,6 +848,9 @@ 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 new file mode 100644 index 00000000000..9a3abe9d9bf --- /dev/null +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -0,0 +1,287 @@ +/** + * @license + * Copyright 2026 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +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 { + 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 mockInitializeTelemetry = vi.hoisted(() => vi.fn()); +const mockStartBackgroundHousekeeping = vi.hoisted(() => vi.fn()); + +vi.mock('@qwen-code/qwen-code-core', () => ({ + createDebugLogger: () => ({ + debug: mockDebug, + warn: mockWarn, + }), + initializeTelemetry: (...args: unknown[]) => mockInitializeTelemetry(...args), +})); + +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, + isInteractive: () => 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(); + vi.useRealTimers(); + mockCheckForUpdates.mockResolvedValue(null); + mockConnectIdeForStartup.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + 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('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(); + + 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); + expect(mockStartBackgroundHousekeeping).toHaveBeenCalledWith( + config, + expect.any(Object), + ); + }); + + 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('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(); + + 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(); + const error = new Error('otel unavailable'); + mockInitializeTelemetry.mockImplementation(() => { + throw error; + }); + + expect(() => + startPostRenderPrefetches(config, makeSettings(), { + initializeTelemetry: true, + }), + ).not.toThrow(); + + await vi.dynamicImportSettled(); + + expect(mockWarn).toHaveBeenCalledWith('telemetry_init failed:', error); + }); + + it('swallows deferred task failures', async () => { + const config = makeConfig(); + const error = new Error('network down'); + mockCheckForUpdates.mockRejectedValue(error); + + expect(() => + startPostRenderPrefetches(config, makeSettings()), + ).not.toThrow(); + + await vi.dynamicImportSettled(); + + 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 () => { + 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..61bf7c3adbb --- /dev/null +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2026 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +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'; + +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 + * 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); + }); +} + +/** + * 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 }); + } 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; initializeTelemetry?: 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 withTimeout( + connectIdeForStartup(config), + 'ide_connect', + DEFERRED_IDE_CONNECT_TIMEOUT_MS, + ); + }); + } + + if (options.initializeTelemetry) { + runDeferredTask('telemetry_init', () => { + initializeTelemetry(config); + }); + } + + if (config.isInteractive()) { + 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..9c8a1021a77 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, @@ -43,12 +42,18 @@ import { getCliVersion } from '../utils/version.js'; const debugLogger = createDebugLogger('STARTUP'); +export interface StartInteractiveUIOptions { + postRenderConnectIde?: boolean; + postRenderInitializeTelemetry?: 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)); @@ -195,19 +200,12 @@ 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: options.postRenderConnectIde ?? false, + initializeTelemetry: + options.postRenderInitializeTelemetry ?? + config.isTelemetryInitializationDeferred(), + }); registerCleanup(async () => { remoteInputWatcher?.shutdown(); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 0931977fcbf..50577b590f9 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, @@ -4055,6 +4056,21 @@ describe('Server Config (config.ts)', () => { }; const config = new Config(paramsWithTelemetry); expect(config.getTelemetryEnabled()).toBe(true); + expect(config.isTelemetryInitializationDeferred()).toBe(false); + 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(config.isTelemetryInitializationDeferred()).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 f0c4e19b8bd..3d29289511c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -908,6 +908,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; @@ -1561,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; @@ -1796,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, @@ -1952,7 +1960,10 @@ export class Config { onModelChange: this.handleModelChange.bind(this), }); - if (this.telemetrySettings.enabled) { + if ( + this.telemetrySettings.enabled && + !this.telemetryInitializationDeferred + ) { initializeTelemetry(this); } @@ -4956,6 +4967,10 @@ export class Config { return this.telemetrySettings.enabled ?? false; } + isTelemetryInitializationDeferred(): boolean { + return this.telemetryInitializationDeferred; + } + getTelemetryLogPromptsEnabled(): boolean { return this.telemetrySettings.logPrompts ?? true; }