Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions docs/design/2026-07-21-lazy-undici-loading.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 7 additions & 3 deletions packages/cli/src/commands/channel/proxy.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<string | undefined> {
const proxyUrl = resolveProxyUrl(cliProxy, settingsProxy);
if (proxyUrl) {
const { EnvHttpProxyAgent, setGlobalDispatcher } = await loadUndici();
setGlobalDispatcher(
new EnvHttpProxyAgent({ httpProxy: proxyUrl, httpsProxy: proxyUrl }),
);
Expand Down
15 changes: 9 additions & 6 deletions packages/cli/src/commands/channel/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
Expand All @@ -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({
Expand All @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/channel/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ export const startCommand: CommandModule<object, { name?: string }> = {
}),
handler: async (argv) => {
const settings = loadSettings(process.cwd());
const proxy = resolveProxy(
const proxy = await resolveProxy(
(argv as Record<string, unknown>)['proxy'] as string | undefined,
settings.merged.proxy as string | undefined,
);
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/services/setup-github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import { promises as fsp } from 'node:fs';
import * as path from 'node:path';
import { ProxyAgent } from 'undici';

import {
getGitHubRepoInfoAsync,
Expand All @@ -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');
Expand Down Expand Up @@ -339,8 +339,10 @@ async function downloadWorkflows(options: {
}): Promise<Array<{ sourcePath: string; content: string }>> {
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) => {
Expand Down
Loading
Loading