diff --git a/docs/design/2026-07-21-lazy-undici-loading.md b/docs/design/2026-07-21-lazy-undici-loading.md new file mode 100644 index 00000000000..61f5bf5e3b0 --- /dev/null +++ b/docs/design/2026-07-21-lazy-undici-loading.md @@ -0,0 +1,110 @@ +# Lazy undici loading (lazy startup phase 3) + +- Status: implemented +- Issue: QwenLM/qwen-code#7264 (candidate 4), follow-up to #4748 +- Predecessors: `2026-07-19-lazy-telemetry-sdk-loading.md`, + `2026-07-19-telemetry-protocol-split.md` + +## Problem + +After the telemetry phases, undici is the single largest remaining +third-party contributor to the ACP eager startup closure: 2057 KiB across +two bundled copies (cli resolves its own `undici`, core resolves another). +Every static `import { … } from 'undici'` anywhere in the closure pulls a +full copy into cold start parse/compile, even though undici is only needed +when a request actually goes out — proxy dispatchers, preconnect, IDE +client fetch options, GitHub setup, self-update. + +The metafile showed eight value-import sites (type-only imports are free): + +| Package | Site | Uses | +| ------- | ------------------------------ | ------------------------------------------ | +| core | `utils/runtimeFetchOptions.ts` | `Agent`, `ProxyAgent`, `EnvHttpProxyAgent` | +| core | `config/config.ts` | `EnvHttpProxyAgent`, `setGlobalDispatcher` | +| core | `ide/ide-client.ts` | `Agent` (IDE HTTP keep-alive) | +| cli | `utils/apiPreconnect.ts` | `fetch` | +| cli | `commands/channel/proxy.ts` | `EnvHttpProxyAgent`, `setGlobalDispatcher` | +| cli | `utils/gitUtils.ts` | `ProxyAgent` | +| cli | `services/setup-github.ts` | `ProxyAgent` | +| cli | `utils/standalone-update.ts` | `fetch` | + +## Design + +All eight sites move to dynamic `import('undici')`, funneled through two +package-local single-flight helpers: + +- `packages/core/src/utils/runtimeFetchOptions.ts` — `loadUndici()`, plus + the existing `preloadRuntimeFetchModule()` now delegates to it. Sync + consumers (`getOrCreateSharedDispatcher`, `buildFetchOptionsWithDispatcher`) + keep their fail-loud `requireUndici()`; async entry points that can await + (`createContentGenerator`, `Config.initialize`, IDE client connect) preload + before any sync construction runs. +- `packages/cli/src/utils/load-undici.ts` — same helper, duplicated on + purpose (see "Why two helpers"). + +Call-site notes: + +- `Config`: the global proxy dispatcher installs asynchronously; the promise + is stored and awaited at the top of `initialize()`, so the dispatcher is in + place before any network activity, matching the previous synchronous + ordering guarantee. +- `createContentGenerator` awaits `preloadRuntimeFetchModule()` before + provider constructors synchronously build undici-backed fetch options. + +## esbuild CJS interop (the hard part) + +esbuild compiles the CJS undici package into a **default-only** dynamic +chunk: `export default require_undici()`, no named exports. So +`const { Agent } = await import('undici')` works in Node and vitest (which +synthesize named exports for CJS) but destructures `undefined` in the +bundle. Local test runs cannot catch this — only a bundled smoke run does. + +`loadUndici()` therefore normalizes: if `Object.keys(mod)` is exactly +`['default']`, unwrap `mod.default`; otherwise return the namespace as-is. +The single-key check (rather than `mod.default ?? mod` or `'default' in mod`) +is deliberate: + +- vitest mock proxies **throw** on access to an undefined `default` export, + so probing `mod.default` breaks every `vi.mock('undici')` test; +- mocks built as `{ ...actual }` may carry a `default` key alongside named + exports and must not be unwrapped. + +## Why two helpers (not one exported from core) + +cli and core resolve **different** undici copies. If cli code called a +core-hosted `loadUndici()`, the `import('undici')` would resolve inside +core's package scope, escaping `vi.mock('undici')` in cli tests — mocks +silently stop intercepting (observed: `ProxyAgent` mock never called in +`setup-github.test.ts`). Keeping one helper per package keeps each package's +tests able to mock their own undici. + +## Guard + +`scripts/check-serve-fast-path-bundle.js` adds undici to +`FORBIDDEN_ACP_PACKAGES`: a static re-import anywhere in the ACP eager +closure fails CI. After the change the eager closure drops from 15.42 MiB / +132 chunks to 13.39 MiB / 130 chunks, undici bytes 2057 KiB → 0; the bundle +retains exactly two dynamic undici entry chunks (one per package copy), both +behind the normalizing helpers. + +## Acceptance (2C4G, #4748 discipline) + +30 paired serial cold starts, control = phase 2 build, candidate = this +change: process→first-session paired P50 −89.5 ms (1336.8 → 1255.2), +candidate faster in 30/30 pairs; preheated path unchanged (P50 80.7 → 78.0); +RSS after first session −8.1 MB. Functional gates (concurrency, telemetry +disabled, legacy single-session) all pass. Full numbers in +`.qwen/e2e-tests/phase3-lazy-undici-bench-results.md`. + +## Alternatives rejected + +- **Single shared helper exported from core**: breaks cli test mocks and + couples cli's copy of undici to core's (the two copies are already on + different versions at HEAD: 7.27.2 vs 7.28.0). +- **Eager top-level preload kicked off at startup**: keeps the parse cost + off the critical path only if nothing awaits it, but the whole point is + that most cold starts never need undici before first-session; preloading + would re-add the CPU contention on 2 cores that phase 2 measured. +- **Replacing undici usage with global `fetch`**: Node's global fetch is + undici, but the code needs `Agent`/`ProxyAgent`/`EnvHttpProxyAgent` + dispatcher options that the global surface does not expose. diff --git a/packages/cli/src/commands/channel/proxy.ts b/packages/cli/src/commands/channel/proxy.ts index 8122d35c73b..a4d0969343e 100644 --- a/packages/cli/src/commands/channel/proxy.ts +++ b/packages/cli/src/commands/channel/proxy.ts @@ -1,5 +1,5 @@ -import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; import { normalizeProxyUrl } from '@qwen-code/qwen-code-core'; +import { loadUndici } from '../../utils/load-undici.js'; /** * Resolve and apply proxy settings for channel service processes. @@ -8,13 +8,17 @@ import { normalizeProxyUrl } from '@qwen-code/qwen-code-core'; * setGlobalDispatcher, but channel runtimes do not call loadCliConfig. This * mirrors that resolution logic and also returns the resolved URL so channel * adapters can configure non-fetch HTTP clients. + * + * Async because undici loads behind a dynamic import to keep it out of the + * eager startup closure (issue #7264). */ -export function resolveProxy( +export async function resolveProxy( cliProxy?: string, settingsProxy?: string, -): string | undefined { +): Promise { const proxyUrl = resolveProxyUrl(cliProxy, settingsProxy); if (proxyUrl) { + const { EnvHttpProxyAgent, setGlobalDispatcher } = await loadUndici(); setGlobalDispatcher( new EnvHttpProxyAgent({ httpProxy: proxyUrl, httpsProxy: proxyUrl }), ); diff --git a/packages/cli/src/commands/channel/start.test.ts b/packages/cli/src/commands/channel/start.test.ts index 242358f8986..3673184baa5 100644 --- a/packages/cli/src/commands/channel/start.test.ts +++ b/packages/cli/src/commands/channel/start.test.ts @@ -226,10 +226,10 @@ beforeEach(() => { }); describe('resolveProxy', () => { - it('prefers the CLI proxy over settings and environment proxies', () => { + it('prefers the CLI proxy over settings and environment proxies', async () => { process.env['HTTPS_PROXY'] = 'http://env.example.com:8080'; - const proxy = resolveProxy( + const proxy = await resolveProxy( 'http://cli.example.com:8080', 'http://settings.example.com:8080', ); @@ -244,10 +244,13 @@ describe('resolveProxy', () => { }); }); - it('prefers settings.proxy over environment proxies', () => { + it('prefers settings.proxy over environment proxies', async () => { process.env['HTTPS_PROXY'] = 'http://env.example.com:8080'; - const proxy = resolveProxy(undefined, 'http://settings.example.com:8080'); + const proxy = await resolveProxy( + undefined, + 'http://settings.example.com:8080', + ); expect(proxy).toBe('http://settings.example.com:8080'); expect(mockEnvHttpProxyAgent).toHaveBeenCalledWith({ @@ -256,10 +259,10 @@ describe('resolveProxy', () => { }); }); - it('falls back to proxy environment variables', () => { + it('falls back to proxy environment variables', async () => { process.env['HTTP_PROXY'] = 'http://env.example.com:8080'; - const proxy = resolveProxy(); + const proxy = await resolveProxy(); expect(proxy).toBe('http://env.example.com:8080'); expect(mockEnvHttpProxyAgent).toHaveBeenCalledWith({ diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index 025e78666e4..c23e51b72a8 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -565,7 +565,7 @@ export const startCommand: CommandModule = { }), handler: async (argv) => { const settings = loadSettings(process.cwd()); - const proxy = resolveProxy( + const proxy = await resolveProxy( (argv as Record)['proxy'] as string | undefined, settings.merged.proxy as string | undefined, ); diff --git a/packages/cli/src/services/setup-github.ts b/packages/cli/src/services/setup-github.ts index 6bde7add071..2f73aefb929 100644 --- a/packages/cli/src/services/setup-github.ts +++ b/packages/cli/src/services/setup-github.ts @@ -6,7 +6,6 @@ import { promises as fsp } from 'node:fs'; import * as path from 'node:path'; -import { ProxyAgent } from 'undici'; import { getGitHubRepoInfoAsync, @@ -15,6 +14,7 @@ import { isGitHubRepositoryAsync, } from '../utils/gitUtils.js'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { loadUndici } from '../utils/load-undici.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; const debugLogger = createDebugLogger('SETUP_GITHUB'); @@ -339,8 +339,10 @@ async function downloadWorkflows(options: { }): Promise> { const internalAbort = new AbortController(); try { + // Lazy-load undici so it stays out of the eager startup closure + // (issue #7264). const dispatcher = options.proxy - ? new ProxyAgent(options.proxy) + ? new (await loadUndici()).ProxyAgent(options.proxy) : undefined; return await Promise.all( GITHUB_WORKFLOW_PATHS.map(async (workflow) => { diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index a1f98faa41f..b2edb1533b0 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { preconnectApi, resetPreconnectState } from './apiPreconnect.js'; +import { + preconnectApi, + resetPreconnectState, + waitForPreconnect, +} from './apiPreconnect.js'; // Mock the shared dispatcher functions from core const { mockGetOrCreateSharedDispatcher, mockDebugLogger } = vi.hoisted(() => { @@ -76,11 +80,12 @@ describe('apiPreconnect', () => { }); describe('resolvedBaseUrl handling', () => { - it('should use resolvedBaseUrl when it is a default URL', () => { + it('should use resolvedBaseUrl when it is a default URL', async () => { preconnectApi('openai', { resolvedBaseUrl: 'https://api.openai.com/v1', proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledWith( 'https://api.openai.com/v1', expect.objectContaining({ method: 'HEAD' }), @@ -103,11 +108,12 @@ describe('apiPreconnect', () => { expect(mockFetch).not.toHaveBeenCalled(); }); - it('should use resolvedBaseUrl when it is a dashscope compatible-mode URL', () => { + it('should use resolvedBaseUrl when it is a dashscope compatible-mode URL', async () => { preconnectApi('openai', { resolvedBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledWith( 'https://dashscope.aliyuncs.com/compatible-mode/v1', expect.objectContaining({ method: 'HEAD' }), @@ -122,56 +128,61 @@ describe('apiPreconnect', () => { expect(mockFetch).not.toHaveBeenCalled(); }); - it('should accept DashScope regional endpoint (sg-singapore)', () => { + it('should accept DashScope regional endpoint (sg-singapore)', async () => { preconnectApi('openai', { resolvedBaseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledWith( 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', expect.objectContaining({ method: 'HEAD' }), ); }); - it('should accept DashScope regional endpoint (us-virginia)', () => { + it('should accept DashScope regional endpoint (us-virginia)', async () => { preconnectApi('openai', { resolvedBaseUrl: 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledWith( 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', expect.objectContaining({ method: 'HEAD' }), ); }); - it('should accept DashScope regional endpoint (cn-hongkong)', () => { + it('should accept DashScope regional endpoint (cn-hongkong)', async () => { preconnectApi('openai', { resolvedBaseUrl: 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledWith( 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', expect.objectContaining({ method: 'HEAD' }), ); }); - it('should fall back to authType default when resolvedBaseUrl is a non-URL sentinel', () => { + it('should fall back to authType default when resolvedBaseUrl is a non-URL sentinel', async () => { preconnectApi('qwen-oauth', { resolvedBaseUrl: 'DYNAMIC_QWEN_OAUTH_BASE_URL', proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledWith( 'https://coding.dashscope.aliyuncs.com', expect.objectContaining({ method: 'HEAD' }), ); }); - it('should fall back to default URL when resolvedBaseUrl is undefined', () => { + it('should fall back to default URL when resolvedBaseUrl is undefined', async () => { preconnectApi('qwen-oauth', { proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledWith( 'https://coding.dashscope.aliyuncs.com', expect.objectContaining({ method: 'HEAD' }), @@ -180,40 +191,44 @@ describe('apiPreconnect', () => { }); describe('preconnect behavior', () => { - it('should use default baseUrl for qwen-oauth', () => { + it('should use default baseUrl for qwen-oauth', async () => { preconnectApi('qwen-oauth', { proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledWith( 'https://coding.dashscope.aliyuncs.com', expect.objectContaining({ method: 'HEAD' }), ); }); - it('should use default baseUrl for openai', () => { + it('should use default baseUrl for openai', async () => { preconnectApi('openai', { proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledWith( 'https://api.openai.com', expect.objectContaining({ method: 'HEAD' }), ); }); - it('should use default baseUrl for anthropic', () => { + it('should use default baseUrl for anthropic', async () => { preconnectApi('anthropic', { proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledWith( 'https://api.anthropic.com', expect.objectContaining({ method: 'HEAD' }), ); }); - it('should pass shared dispatcher on Node.js runtime', () => { + it('should pass shared dispatcher on Node.js runtime', async () => { preconnectApi('qwen-oauth', { proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ @@ -222,24 +237,26 @@ describe('apiPreconnect', () => { ); }); - it('should pass configured proxy to shared dispatcher', () => { + it('should pass configured proxy to shared dispatcher', async () => { preconnectApi('qwen-oauth', { proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockGetOrCreateSharedDispatcher).toHaveBeenCalledWith( 'http://proxy.example.com:8080', ); }); - it('should not fire twice', () => { + it('should not fire twice', async () => { preconnectApi('qwen-oauth', { proxy: 'http://proxy.example.com:8080', }); preconnectApi('openai', { proxy: 'http://proxy.example.com:8080' }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledTimes(1); }); - it('should retry when targetUrl was unavailable on first call', () => { + it('should retry when targetUrl was unavailable on first call', async () => { // First call: unknown authType, no resolvedBaseUrl → no targetUrl preconnectApi('unknown-auth', { proxy: 'http://proxy.example.com:8080', @@ -250,6 +267,7 @@ describe('apiPreconnect', () => { preconnectApi('qwen-oauth', { proxy: 'http://proxy.example.com:8080', }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledWith( 'https://coding.dashscope.aliyuncs.com', @@ -266,13 +284,14 @@ describe('apiPreconnect', () => { ); }); - it('should allow a later proxy preconnect after a no-proxy skip', () => { + it('should allow a later proxy preconnect after a no-proxy skip', async () => { // First call: no proxy, no useful undici pool to warm. preconnectApi('qwen-oauth'); expect(mockFetch).not.toHaveBeenCalled(); // Second call: proxy is now available, so preconnect should still fire. preconnectApi('qwen-oauth', { proxy: 'http://proxy.example.com:8080' }); + await waitForPreconnect(); expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockGetOrCreateSharedDispatcher).toHaveBeenCalledWith( 'http://proxy.example.com:8080', @@ -295,8 +314,7 @@ describe('apiPreconnect', () => { proxy: 'http://token@proxy.local:8080', }); - await Promise.resolve(); - await Promise.resolve(); + await waitForPreconnect(); expect(mockDebugLogger.debug).toHaveBeenCalledWith( 'Preconnect failed (ignored): Error: connect ECONNREFUSED @proxy.local:8080', @@ -306,7 +324,7 @@ describe('apiPreconnect', () => { ); }); - it('should handle synchronous dispatcher errors gracefully', () => { + it('should handle dispatcher errors gracefully', () => { mockGetOrCreateSharedDispatcher.mockImplementation(() => { throw new Error('Failed to create dispatcher'); }); @@ -315,7 +333,7 @@ describe('apiPreconnect', () => { ).not.toThrow(); }); - it('should redact proxy credentials from synchronous dispatcher errors', () => { + it('should redact proxy credentials from dispatcher errors', async () => { mockGetOrCreateSharedDispatcher.mockImplementation(() => { throw new Error('connect ECONNREFUSED user:pass@proxy.local:8080'); }); @@ -324,6 +342,8 @@ describe('apiPreconnect', () => { proxy: 'http://user:pass@proxy.local:8080', }); + await waitForPreconnect(); + expect(mockDebugLogger.debug).toHaveBeenCalledWith( 'Preconnect failed (ignored): Error: connect ECONNREFUSED @proxy.local:8080', ); diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index ebeb3c595ca..9bdfb74b18d 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -20,13 +20,15 @@ import { detectRuntime, getAllProviderBaseUrls, getOrCreateSharedDispatcher, + preloadRuntimeFetchModule, redactProxyCredentials, } from '@qwen-code/qwen-code-core'; -import { fetch as undiciFetch } from 'undici'; +import { loadUndici } from './load-undici.js'; const debugLogger = createDebugLogger('PRECONNECT'); let preconnectFired = false; +let preconnectInFlight: Promise | undefined; /** * Default API base URLs by AuthType. @@ -189,9 +191,12 @@ export function preconnectApi( preconnectFired = true; debugLogger.debug(`Preconnecting to: ${targetUrl}`); - try { + // Fire-and-forget: undici loads behind a dynamic import to keep it out of + // the eager startup closure (issue #7264). + preconnectInFlight = (async () => { // Use the same shared undici dispatcher that SDK clients will use, // so the warmed TCP+TLS connection is reused by subsequent API calls. + await preloadRuntimeFetchModule(); const dispatcher = getOrCreateSharedDispatcher(proxy); // Fire HEAD request to warm connection (fire-and-forget). @@ -199,26 +204,21 @@ export function preconnectApi( // and fetch come from the same undici version — Node's bundled undici // may differ in major version from the packaged one (e.g. v8 vs v7), // causing handler-interface mismatches like `invalid onError method`. - undiciFetch(targetUrl, { + const { fetch: undiciFetch } = await loadUndici(); + await undiciFetch(targetUrl, { method: 'HEAD', signal: AbortSignal.timeout(5_000), headers: { 'User-Agent': 'QwenCode-Preconnect/1.0', }, dispatcher, - }) - .then(() => { - debugLogger.debug('Preconnect completed'); - }) - .catch((error) => { - const redactedError = redactProxyCredentials(String(error)); - debugLogger.debug(`Preconnect failed (ignored): ${redactedError}`); - }); - } catch (error) { + }); + debugLogger.debug('Preconnect completed'); + })().catch((error) => { // Preconnect failure doesn't affect main flow const redactedError = redactProxyCredentials(String(error)); debugLogger.debug(`Preconnect failed (ignored): ${redactedError}`); - } + }); } /** @@ -227,4 +227,13 @@ export function preconnectApi( */ export function resetPreconnectState(): void { preconnectFired = false; + preconnectInFlight = undefined; +} + +/** + * Wait for the in-flight preconnect request, if any (for testing only) + * @internal + */ +export function waitForPreconnect(): Promise { + return preconnectInFlight ?? Promise.resolve(); } diff --git a/packages/cli/src/utils/gitUtils.test.ts b/packages/cli/src/utils/gitUtils.test.ts index 9228f70940c..1813959e8c4 100644 --- a/packages/cli/src/utils/gitUtils.test.ts +++ b/packages/cli/src/utils/gitUtils.test.ts @@ -271,4 +271,23 @@ describe('getLatestRelease', async () => { ); await expect(getLatestGitHubRelease()).resolves.toBe('v1.2.3'); }); + + it('uses ProxyAgent when a proxy is provided', async () => { + const mockProxyAgent = vi.fn(); + vi.doMock('undici', () => ({ + ProxyAgent: mockProxyAgent, + })); + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: 'v1.2.4' }), + } as Response), + ); + const { getLatestGitHubRelease } = await import('./gitUtils.js'); + await expect( + getLatestGitHubRelease('http://proxy.local:8080'), + ).resolves.toBe('v1.2.4'); + expect(mockProxyAgent).toHaveBeenCalledWith('http://proxy.local:8080'); + vi.doUnmock('undici'); + }); }); diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index 552b8f904bc..8dbefb90c4f 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -5,8 +5,8 @@ */ import * as childProcess from 'node:child_process'; -import { ProxyAgent } from 'undici'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { loadUndici } from './load-undici.js'; const debugLogger = createDebugLogger('GIT'); @@ -92,6 +92,12 @@ export const getLatestGitHubRelease = async ( const endpoint = `https://api.github.com/repos/QwenLM/qwen-code-action/releases/latest`; + // Lazy-load undici so it stays out of the eager startup closure + // (issue #7264). + const dispatcher = proxy + ? new (await loadUndici()).ProxyAgent(proxy) + : undefined; + const response = await fetch(endpoint, { method: 'GET', headers: { @@ -99,7 +105,7 @@ export const getLatestGitHubRelease = async ( 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28', }, - dispatcher: proxy ? new ProxyAgent(proxy) : undefined, + dispatcher, signal: AbortSignal.any([AbortSignal.timeout(30_000), controller.signal]), } as RequestInit); diff --git a/packages/cli/src/utils/load-undici.test.ts b/packages/cli/src/utils/load-undici.test.ts new file mode 100644 index 00000000000..1b028fa9dc6 --- /dev/null +++ b/packages/cli/src/utils/load-undici.test.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; + +const makeModule = ( + overrides: Record = {}, +): Record => ({ + Agent: class {}, + ProxyAgent: class {}, + EnvHttpProxyAgent: class {}, + fetch: vi.fn(), + setGlobalDispatcher: vi.fn(), + ...overrides, +}); + +describe('loadUndici CJS interop normalization', () => { + it('unwraps a default-only esbuild chunk', async () => { + vi.resetModules(); + vi.doMock('undici', () => ({ + default: makeModule({ Agent: class DefaultAgent {} }), + })); + const { loadUndici } = await import('./load-undici.js'); + const mod = await loadUndici(); + expect(mod.Agent.name).toBe('DefaultAgent'); + vi.doUnmock('undici'); + }); + + it('uses named exports when the module is not default-only', async () => { + vi.resetModules(); + vi.doMock('undici', () => makeModule({ Agent: class NamedAgent {} })); + const { loadUndici } = await import('./load-undici.js'); + const mod = await loadUndici(); + expect(mod.Agent.name).toBe('NamedAgent'); + vi.doUnmock('undici'); + }); + + it('handles mixed default + named shape by preferring named exports', async () => { + vi.resetModules(); + vi.doMock('undici', () => ({ + ...makeModule({ Agent: class NamedAgent {} }), + default: makeModule({ Agent: class DefaultAgent {} }), + })); + const { loadUndici } = await import('./load-undici.js'); + const mod = await loadUndici(); + expect(mod.Agent.name).toBe('NamedAgent'); + vi.doUnmock('undici'); + }); +}); diff --git a/packages/cli/src/utils/load-undici.ts b/packages/cli/src/utils/load-undici.ts new file mode 100644 index 00000000000..c468b8e906b --- /dev/null +++ b/packages/cli/src/utils/load-undici.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +type UndiciModule = typeof import('undici'); + +export type { UndiciModule }; + +let undiciModulePromise: Promise | undefined; + +/** + * Load undici behind a dynamic import so it stays out of the eager startup + * closure (issue #7264). esbuild compiles the CJS undici package into a + * default-only dynamic chunk (no named exports), while Node and vitest + * expose named exports directly — unwrap only the default-only shape. + * + * Kept package-local (mirroring core's loadUndici) so `vi.mock('undici')` + * in cli tests intercepts the import; core resolves its own undici copy. + */ +export async function loadUndici(): Promise { + undiciModulePromise ??= import('undici').then((mod) => { + const keys = Object.keys(mod); + if (keys.length === 1 && keys[0] === 'default') { + return (mod as unknown as { default: UndiciModule }).default; + } + return mod; + }); + return undiciModulePromise; +} diff --git a/packages/cli/src/utils/standalone-update.ts b/packages/cli/src/utils/standalone-update.ts index f7dfef54e96..c95fab1042b 100644 --- a/packages/cli/src/utils/standalone-update.ts +++ b/packages/cli/src/utils/standalone-update.ts @@ -12,10 +12,11 @@ import { Readable, Transform } from 'node:stream'; import { spawn, execFile } from 'node:child_process'; import { pipeline } from 'node:stream/promises'; import type { Stats } from 'node:fs'; -import { fetch } from 'undici'; +import type { Response as UndiciResponse } from 'undici'; import * as tar from 'tar'; import type { ReadEntry } from 'tar'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { loadUndici } from './load-undici.js'; import { verifySignature } from './standalone-update-verify.js'; import { updateEventEmitter } from './updateEventEmitter.js'; import { t } from '../i18n/index.js'; @@ -38,7 +39,6 @@ const VALID_TARGETS = new Set([ const SEMVER_RE = /^v?\d+\.\d+\.\d+(-[\w.]+)?$/; -type UndiciResponse = Awaited>; type TarFilterEntry = Stats | ReadEntry | { type?: string; linkpath?: unknown }; function normalizeVersion(version: string): string { @@ -71,6 +71,9 @@ async function tryFetch( | { response?: undefined; error: Error } > { try { + // Lazy-load undici so it stays out of the eager startup closure + // (issue #7264). + const { fetch } = await loadUndici(); const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 09af8d59962..5311c26b7a5 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -11,9 +11,6 @@ import * as fsPromises from 'node:fs/promises'; import * as path from 'node:path'; import process from 'node:process'; -// External dependencies -import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; - // Types import type { ContentGenerator, @@ -168,6 +165,7 @@ import { WorkspaceContext } from '../utils/workspaceContext.js'; import { type ToolName } from '../utils/tool-utils.js'; import { FatalConfigError, getErrorMessage } from '../utils/errors.js'; import { normalizeProxyUrl } from '../utils/proxyUtils.js'; +import { loadUndici, redactProxyError } from '../utils/runtimeFetchOptions.js'; // Local config modules import type { FileFilteringOptions } from './constants.js'; @@ -1845,6 +1843,7 @@ export class Config { rule: string, ) => Promise; private initialized: boolean = false; + private proxyDispatcherReady?: Promise; storage: Storage; private runtimeStatusWrite: Promise = Promise.resolve(); private readonly fileExclusions: FileExclusions; @@ -2236,9 +2235,32 @@ export class Config { // `--proxy` / `settings.proxy` value (resolved by `getProxy()`) // overrides env `http(s)_proxy`; `NO_PROXY` continues to come from the // environment. See issue #3696 (local MCP + corporate proxy). - setGlobalDispatcher( - new EnvHttpProxyAgent({ httpProxy: proxyUrl, httpsProxy: proxyUrl }), - ); + // + // undici loads behind a dynamic import to keep it out of the eager + // startup closure (issue #7264); initialize() awaits this promise so + // the dispatcher is installed before any network activity. + this.proxyDispatcherReady = loadUndici() + .then(({ EnvHttpProxyAgent, setGlobalDispatcher }) => { + setGlobalDispatcher( + new EnvHttpProxyAgent({ + httpProxy: proxyUrl, + httpsProxy: proxyUrl, + }), + ); + }) + .catch((error) => { + // Redact before logging: the error can embed the proxy URL with + // credentials. Rethrow so initialize() fails loudly, matching the + // old synchronous constructor behavior. + this.debugLogger.error( + 'Failed to install proxy dispatcher:', + redactProxyError(error), + ); + throw error; + }); + // Swallow an early rejection so it cannot become an unhandledRejection + // before initialize() awaits (and surfaces) the stored promise. + this.proxyDispatcherReady.catch(() => {}); } this.geminiClient = new GeminiClient(this); this.chatRecordingService = this.chatRecordingEnabled @@ -2331,6 +2353,7 @@ export class Config { options?: ConfigInitializeOptions, ): Promise { this.debugLogger.info('Config initialization started'); + await this.proxyDispatcherReady; if (options?.skipFileCheckpointing === true) { this.fileCheckpointingEnabled = false; this.fileHistoryService = undefined; diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index a73e3d2a86b..17a75c5932b 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -87,6 +87,13 @@ describe('AnthropicContentGenerator', () => { beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); + // The generator's constructor builds undici-backed fetch options + // synchronously; production code preloads undici in + // createContentGenerator, and resetModules() clears that state. + const { preloadRuntimeFetchModule } = await import( + '../../utils/runtimeFetchOptions.js' + ); + await preloadRuntimeFetchModule(); savedMaxOutputTokensEnv = process.env[MAX_OUTPUT_TOKENS_ENV]; delete process.env[MAX_OUTPUT_TOKENS_ENV]; diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index cb53eeacd9a..9f0f2cbc8cd 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -30,6 +30,7 @@ import { StrictMissingModelIdError, } from '../models/modelConfigErrors.js'; import { PROVIDER_SOURCED_FIELDS } from '../models/constants.js'; +import { preloadRuntimeFetchModule } from '../utils/runtimeFetchOptions.js'; import type { ReasoningEffort } from './reasoning-effort.js'; /** @@ -368,6 +369,11 @@ export async function createContentGenerator( throw new Error('ContentGeneratorConfig must have an authType'); } + // Provider constructors below synchronously build undici-backed fetch + // options; load undici here so it stays out of the eager startup closure + // (issue #7264). + await preloadRuntimeFetchModule(); + let baseGenerator: ContentGenerator; try { diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 183e02ed024..f85730a4fda 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -23,10 +23,10 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import * as os from 'node:os'; import * as path from 'node:path'; -import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici'; import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { IDE_REQUEST_TIMEOUT_MS } from './constants.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { loadUndici } from '../utils/runtimeFetchOptions.js'; const debugLogger = createDebugLogger('IDE'); @@ -892,10 +892,21 @@ export class IdeClient { // server even when HTTP_PROXY is set const existingNoProxy = process.env['NO_PROXY'] || ''; const noProxyHosts = [existingNoProxy, ideHost]; - const agent = new EnvHttpProxyAgent({ - noProxy: noProxyHosts.filter(Boolean).join(','), - }); + // undici loads behind a dynamic import to keep it out of the eager + // startup closure (issue #7264); the agent is built once on first use. + const undiciReady = loadUndici().then( + ({ EnvHttpProxyAgent, fetch: undiciFetch }) => ({ + agent: new EnvHttpProxyAgent({ + noProxy: noProxyHosts.filter(Boolean).join(','), + }), + undiciFetch, + }), + ); + // Swallow an early rejection so it cannot become an unhandledRejection + // before the first fetch call awaits (and surfaces) it. + undiciReady.catch(() => {}); return async (url: string | URL, init?: RequestInit): Promise => { + const { agent, undiciFetch } = await undiciReady; const fetchOptions: RequestInit & { dispatcher?: unknown } = { ...init, dispatcher: agent, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 48fe7aa9ac7..2899c4b15fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -540,6 +540,7 @@ export { detectRuntime, getOrCreateSharedDispatcher, isTlsVerificationDisabled, + preloadRuntimeFetchModule, redactProxyCredentials, } from './utils/runtimeFetchOptions.js'; export * from './utils/runtimeStatus.js'; diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts index 68b0e1600fe..691da351a80 100644 --- a/packages/core/src/tools/web-search.ts +++ b/packages/core/src/tools/web-search.ts @@ -10,7 +10,10 @@ import type { Config } from '../config/config.js'; import { AuthType } from '../core/contentGenerator.js'; import { resolveRequestTimeout } from '../core/openaiContentGenerator/constants.js'; import { DASHSCOPE_REGIONAL_HOSTS } from '../core/openaiContentGenerator/provider/dashscope.js'; -import { buildRuntimeFetchOptions } from '../utils/runtimeFetchOptions.js'; +import { + buildRuntimeFetchOptions, + preloadRuntimeFetchModule, +} from '../utils/runtimeFetchOptions.js'; import { buildModelIdContext, resolveModelId } from '../utils/modelId.js'; import { delay } from '../utils/retry.js'; import { ToolErrorType } from './tool-error.js'; @@ -622,6 +625,10 @@ class WebSearchToolInvocation extends BaseToolInvocation< } const backend = gate.backend; + // The sync option builder below requires undici to be loaded (issue + // #7264); web search runs outside the content-generator preload path. + await preloadRuntimeFetchModule(); + const startedAt = Date.now(); const apiKey = process.env[backend.apiKeyEnvKey]; const client = new OpenAI({ diff --git a/packages/core/src/utils/runtime-fetch-options.no-proxy.test.ts b/packages/core/src/utils/runtime-fetch-options.no-proxy.test.ts index b6805550d2c..5b74f403894 100644 --- a/packages/core/src/utils/runtime-fetch-options.no-proxy.test.ts +++ b/packages/core/src/utils/runtime-fetch-options.no-proxy.test.ts @@ -7,10 +7,19 @@ import { once } from 'node:events'; import { createServer } from 'node:http'; import { connect as netConnect, type AddressInfo } from 'node:net'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import { fetch as undiciFetch, type Dispatcher } from 'undici'; import { getOrCreateSharedDispatcher, + preloadRuntimeFetchModule, resetDispatcherCache, } from './runtimeFetchOptions.js'; @@ -20,6 +29,10 @@ describe('shared proxy dispatcher NO_PROXY behavior', () => { no_proxy: process.env['no_proxy'], }; + beforeAll(async () => { + await preloadRuntimeFetchModule(); + }); + beforeEach(() => { delete process.env['NO_PROXY']; delete process.env['no_proxy']; diff --git a/packages/core/src/utils/runtimeFetchOptions.test.ts b/packages/core/src/utils/runtimeFetchOptions.test.ts index f872a5c7bdd..a5c99c6fce4 100644 --- a/packages/core/src/utils/runtimeFetchOptions.test.ts +++ b/packages/core/src/utils/runtimeFetchOptions.test.ts @@ -4,7 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, + afterEach, +} from 'vitest'; // Hoist mockWarn and mockConsoleError so they're available to both the vi.mock and test cases const { mockWarn, mockConsoleError } = vi.hoisted(() => ({ @@ -59,6 +67,7 @@ import { extractHostnameFromProxyUrl, getOrCreateSharedDispatcher, isTlsVerificationDisabled, + preloadRuntimeFetchModule, redactProxyCredentials, redactProxyError, resetDispatcherCache, @@ -66,6 +75,12 @@ import { type UndiciOptions = Record; +// The sync option builders require undici to be preloaded (issue #7264); +// vi.mock('undici') intercepts the dynamic import, so this loads the mock. +beforeAll(async () => { + await preloadRuntimeFetchModule(); +}); + describe('buildRuntimeFetchOptions (node runtime)', () => { beforeEach(() => { resetDispatcherCache(); @@ -704,3 +719,13 @@ describe('extractHostnameFromProxyUrl', () => { ); }); }); + +describe('requireUndici guard', () => { + it('throws an actionable error when a sync builder runs before preload', async () => { + vi.resetModules(); + const fresh = await import('./runtimeFetchOptions.js'); + expect(() => + fresh.getOrCreateSharedDispatcher('http://proxy.local'), + ).toThrow(/undici is not loaded yet; await preloadRuntimeFetchModule\(\)/); + }); +}); diff --git a/packages/core/src/utils/runtimeFetchOptions.ts b/packages/core/src/utils/runtimeFetchOptions.ts index b88482881b6..4f97171c1da 100644 --- a/packages/core/src/utils/runtimeFetchOptions.ts +++ b/packages/core/src/utils/runtimeFetchOptions.ts @@ -4,17 +4,54 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - Agent, - EnvHttpProxyAgent, - fetch as undiciFetch, - type Dispatcher, -} from 'undici'; +import type { Dispatcher } from 'undici'; import { createDebugLogger } from './debugLogger.js'; const debugLogger = createDebugLogger('RUNTIME_FETCH'); +type UndiciModule = typeof import('undici'); + +let undiciModule: UndiciModule | undefined; +let undiciModulePromise: Promise | undefined; + +/** + * Load undici behind a dynamic import so it stays out of the eager startup + * closure (issue #7264). esbuild compiles the CJS undici package into a + * default-only dynamic chunk (no named exports), while Node and vitest + * expose named exports directly — unwrap only the default-only shape. + */ +export async function loadUndici(): Promise { + undiciModulePromise ??= import('undici').then((mod) => { + const keys = Object.keys(mod); + if (keys.length === 1 && keys[0] === 'default') { + return (mod as unknown as { default: UndiciModule }).default; + } + return mod; + }); + return undiciModulePromise; +} + +/** + * Async entry points that lead to the synchronous dispatcher builders below + * (content generator creation, preconnect) must await this before calling + * them. + */ +export async function preloadRuntimeFetchModule(): Promise { + if (undiciModule) return; + undiciModule = await loadUndici(); +} + +function requireUndici(): UndiciModule { + if (!undiciModule) { + throw new Error( + 'undici is not loaded yet; await preloadRuntimeFetchModule() before ' + + 'building runtime fetch options or dispatchers', + ); + } + return undiciModule; +} + /** * JavaScript runtime type */ @@ -219,6 +256,7 @@ export function getOrCreateSharedDispatcher( return cached; } + const { EnvHttpProxyAgent } = requireUndici(); const dispatcher = new EnvHttpProxyAgent({ httpProxy: proxyUrl, httpsProxy: proxyUrl, @@ -644,6 +682,7 @@ function buildFetchOptionsWithDispatcher( proxyUrl?: string, ): OpenAIRuntimeFetchOptions | AnthropicRuntimeFetchOptions { const insecure = isTlsVerificationDisabled(); + const { Agent, fetch: undiciFetch } = requireUndici(); // When no proxy is configured, use a cached plain undici Agent with disabled // timeouts (headersTimeout: 0, bodyTimeout: 0). This prevents undici's 300s // default bodyTimeout from aborting long-running requests to local LLM diff --git a/scripts/check-serve-fast-path-bundle.js b/scripts/check-serve-fast-path-bundle.js index 36be8831828..4b0c3b1954a 100644 --- a/scripts/check-serve-fast-path-bundle.js +++ b/scripts/check-serve-fast-path-bundle.js @@ -209,6 +209,10 @@ const FORBIDDEN_ACP_TELEMETRY_PACKAGES = [ const FORBIDDEN_ACP_PACKAGES = [ ...FORBIDDEN_ACP_UI_PACKAGES, ...FORBIDDEN_ACP_TELEMETRY_PACKAGES, + // undici loads behind dynamic import()s at its use sites (issue #7264 + // candidate 4); a static re-import anywhere in the ACP closure would pull + // ~1 MiB per bundled copy back into every cold start. + { label: 'undici vendor package', packageName: 'undici' }, ]; export function normalizeMetafilePath(filePath) {