perf(startup): Load undici lazily behind package-local dynamic imports - #7455
Conversation
E2E Test & Benchmark ReportEnvironment: 2C4G Linux (Alibaba Cloud ECS), bundled CLI ( Cold first session (30 pairs)
Preheated (30 pairs)
No regression; candidate tail is tighter. Functional checks (same run)
Bundle closure & interop (macOS, this branch)
|
|
Thanks for the PR! Template looks good ✓ Problem: observed performance issue with strong evidence — 30 paired cold-start benchmarks showing −89.5 ms P50 (candidate faster in 30/30 pairs), bundle closure shrinking from 15.42 → 13.39 MiB, and RSS −8.1 MB. Linked to #4748 (daemon cold start) and implements candidate 4 from #7264. This is a measured, tracked problem, not a theoretical one. Direction: aligned. Daemon cold start is a tracked priority (#4748), and this is the next candidate in the lazy-loading plan (#7264) after the telemetry phases. CHANGELOG reference: Claude Code has done similar lazy-loading work for startup performance (e.g. lazy SDK loading in 1.0.x releases), confirming the area is relevant. Size: 202 production logic lines (additions + deletions, excluding tests/docs), 114 test lines, 110 design-doc lines. Core paths touched ( Approach: the scope feels right. All 19 changed files serve the single goal of moving undici behind dynamic imports — 8 value-import sites converted, a single-flight normalizer per package (justified: different undici copies, test mock isolation), ordering guarantees preserved via stored promises, and a bundle guard to prevent regression. No unrelated changes or drive-by refactors. The design doc clearly explains the esbuild CJS interop trap and the alternatives rejected. Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:已观测到的性能问题,有充分证据——30 对冷启动基准测试显示 P50 −89.5 ms(30/30 对 candidate 全部更快),bundle 闭包从 15.42 → 13.39 MiB,RSS −8.1 MB。关联 #4748(daemon 冷启动),实现 #7264 中的候选项 4。这是经过测量和跟踪的问题,不是理论性的。 方向:对齐。Daemon 冷启动是跟踪中的优先级(#4748),这是 telemetry 阶段之后懒加载计划(#7264)中的下一个候选项。CHANGELOG 参考:Claude Code 也做过类似的启动性能懒加载工作(如 1.0.x 版本中的 SDK 懒加载),确认该方向相关。 规模:202 行生产逻辑(增删合计,不含测试/文档),114 行测试,110 行设计文档。触及核心路径( 方案:范围合理。全部 19 个改动文件都服务于将 undici 移入动态 import 这一单一目标——8 个值导入点转换、每包一个单飞归一化 helper(有正当理由:不同的 undici 拷贝、测试 mock 隔离)、通过存储的 promise 保持顺序保证、以及防止回归的 bundle 守卫。没有无关改动或顺手重构。设计文档清楚解释了 esbuild CJS 互操作陷阱和被否决的替代方案。 进入代码审查 🔍 — Qwen Code · qwen3.7-max Reviewed at |
Code ReviewClean implementation. The approach is exactly what this problem calls for: replace 8 static The esbuild CJS interop handling is the subtle part and it's done right — the The ordering guarantee for the proxy dispatcher is preserved correctly: No correctness bugs, security holes, or regressions found. No AGENTS.md violations — the code follows project conventions (ESM, no One observation (non-blocking): the error handling in Files changed (19)
Unit TestsAll 128 tests pass across both packages:
Typecheck clean for both Real-Scenario TestingDev build (
|
|
Confidence: 5/5 — clean across every stage; would merge without hesitation. This is a well-executed performance PR. The problem is real and measured (−89.5 ms P50 cold start, 30/30 paired benchmarks, −2 MiB eager closure), the implementation is the minimal change that solves it, and the tricky part — esbuild's CJS default-only chunk shape — is handled correctly and verified with a bundled CLI smoke test that unit tests can't cover. Going back to my independent proposal: I would have done essentially the same thing — dynamic imports at each use site, a normalizer for the CJS interop, preload/require split for sync consumers, stored promise for the proxy dispatcher ordering. The PR matches or exceeds that in every respect. The per-package helper duplication is the right call (different undici copies, test mock isolation), and the design doc explains the rejected alternatives clearly. Every change in the diff serves the stated goal. No drive-by refactors, no scope creep. The bundle guard is a nice touch — it makes the invariant self-enforcing. If I had to maintain this in six months, I'd thank the author: the code is straightforward, the comments explain the non-obvious "why", and the fail-loud 中文说明置信度:5/5 —— 每个阶段都干净;毫不犹豫可以合并。 这是一个执行良好的性能 PR。问题真实且有测量数据(冷启动 P50 −89.5 ms,30/30 对基准,急切闭包 −2 MiB),实现是解决问题的最小改动,微妙的部分——esbuild 的 CJS default-only chunk 形态——处理正确,并通过单元测试无法覆盖的打包 CLI 冒烟测试验证。 回顾我的独立方案:我基本上会做同样的事——每个使用点动态 import、CJS 互操作归一化、同步消费者的 preload/require 分离、代理 dispatcher 顺序的存储 promise。PR 在每个方面都达到或超过了这个方案。每包复制 helper 是正确的选择(不同的 undici 拷贝、测试 mock 隔离),设计文档清楚解释了被否决的替代方案。 diff 中的每个改动都服务于既定目标。没有顺手重构,没有范围膨胀。bundle 守卫是一个很好的补充——它使不变量自我执行。如果六个月后我要维护这段代码,我会感谢作者:代码直白,注释解释了非显而易见的"为什么",fail-loud 的 — Qwen Code · qwen3.7-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
Address review: web search builds fetch options outside the content generator preload path, so 33 web-search tests (and any standalone search invocation) hit the requireUndici fail-loud guard. Also redact and rethrow proxy dispatcher install failures, guard early promise rejections against unhandledRejection, and pin the guard message with a test.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.
— qwen3.7-max via Qwen Code /review
Address review suggestions: add parameterized tests for the CJS unwrap normalization used by both core and cli loadUndici helpers, and verify getLatestGitHubRelease instantiates ProxyAgent when a proxy argument is passed.
Export UndiciModule type and loosen test helper typing so the cli package builds under tsc --build.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
— qwen3.7-max via Qwen Code /review
Local verification — ✅ all headline claims reproduceI verified this PR locally as a maintainer on a real Environment: Linux, Node What I confirmed
Structural evidence (bundle guard + metafile + chunk shape)
Runtime A/B (the trap is real, and invisible to vitest)
Repro# worktrees: PR head + base (parent of first PR commit)
git worktree add wt-pr 301c9c7 && git worktree add --detach wt-base 567de03
for d in wt-pr wt-base; do (cd $d && npm ci && DEV=true npm run bundle); done
# [1] guard [2]/[3] metafile [4] chunk shape
cd wt-pr && node scripts/check-serve-fast-path-bundle.js
grep -c 'export{' dist/chunks/undici-*.js # -> 0
grep -o 'export default [a-z_()]*' dist/chunks/undici-*.js
# [6] bundled round-trip vs a mock OpenAI server (OPENAI_* + QWEN_DEFAULT_AUTH_TYPE=openai)
node dist/cli.js -p "reply with exactly: ok" # -> ok
# A/B: patch loadUndici to `import('undici').then(m=>m)`, rebuild core+bundle -> "Agent is not a constructor"
# [7] ordering, against built core
node -e "const m=await import('./packages/core/dist/src/utils/runtimeFetchOptions.js');
try{m.getOrCreateSharedDispatcher('http://p')}catch(e){console.log('throws:',e.message)}
await m.preloadRuntimeFetchModule(); console.log(m.getOrCreateSharedDispatcher('http://p').constructor.name)"Note (not a blocker)I also tried a live Verdict: verification-wise this is solid — the perf claim (undici out of cold start, ~2 MiB closure drop) and the subtle esbuild-interop fix both hold up under real builds, and the new guard + tests genuinely lock them in. LGTM from my side. 中文版(合并参考)本地验证 — ✅ 所有关键结论均可复现我作为维护者在真实 环境: Linux,Node 确认项
结构性证据(见上方第一张图)
运行时 A/B(陷阱是真实的,且单测看不见,见上方第二张图)
说明(非阻塞)我还尝试了真实 结论: 从验证角度看非常扎实——性能结论(undici 移出冷启动、闭包缩小约 2 MiB)与那个微妙的 esbuild 互操作修复在真实构建下都成立,新增的守卫 + 测试也确实把它们锁住了。我这边 LGTM。 |


perf(startup): load undici lazily behind package-local dynamic imports
What this PR does
Moves the undici HTTP client out of the eager startup closure. Undici was the single largest remaining third-party contributor to ACP child cold start after the telemetry work: about 2 MiB of parse/compile cost across two bundled copies, paid on every startup even though undici is only needed once a request actually goes out — proxy dispatchers, API preconnect, IDE client fetch, GitHub setup, self-update. All eight value-import sites now load undici behind a dynamic import, funneled through a small single-flight helper kept in each package.
The helper also solves a bundler interoperability trap that motivated its existence: esbuild compiles the CommonJS undici package into a default-only dynamic chunk with no named exports, so a plain
const { Agent } = await import('undici')works in Node and under vitest but destructuresundefinedin the bundled CLI — a failure mode that local test runs cannot catch. The helper normalizes the module shape (unwrapping only the exact default-only form), and the helper is deliberately duplicated per package rather than shared, because each package resolves its own undici copy and a shared helper would silently escape test mocks in the other package.Two ordering guarantees are preserved explicitly. The global proxy dispatcher that used to install synchronously during config construction now installs behind a stored promise that config initialization awaits, so the dispatcher is always in place before any network activity. The channel proxy path awaits the same installation before startup proceeds. The existing bundle-closure guard gains a check that fails CI if a static undici import ever re-enters the ACP eager closure.
Why it's needed
Daemon cold start (#4748) still paid undici's module cost in every ACP child process before this change. On the 2C4G reference machine, dropping it from the eager closure is worth −89.5 ms P50 of process-to-first-session latency (candidate faster in all 30 of 30 benchmark pairs), on top of the telemetry lazy-loading gains. The eager closure shrinks from 15.42 MiB to 13.39 MiB, and resident memory after the first session drops by about 8 MB.
Reviewer Test Plan
How to verify
npm run build && cross-env DEV=true npm run bundle && node scripts/check-serve-fast-path-bundle.jsnow also fails on any static undici import in the ACP closure.--proxyor settings): outbound requests still go through the proxy dispatcher; the dispatcher is installed before config initialization completes. Same for the channel proxy path.NO_PROXYfor the IDE host.node dist/cli.js -p "reply with exactly: ok"completes a real model round-trip — this exercises the esbuild default-only chunk shape end to end.cd packages/core && npx vitest run src/utils/runtimeFetchOptions.test.ts src/utils/runtime-fetch-options.no-proxy.test.tsandcd packages/cli && npx vitest run src/utils/apiPreconnect.test.ts src/commands/channel/start.test.ts src/services/setup-github.test.ts.Evidence (Before & After)
N/A (no UI change). Paired benchmark on a 2C4G Linux host, 30 pairs per scenario, control = the telemetry-split build, candidate = this change:
Tested on
Environment (optional)
macOS: unit tests, typecheck, lint, bundle guards, and a real-model smoke run against the bundled CLI. Linux (2C4G): paired cold/preheated benchmarks against the bundled CLI.
Risk & Scope
--proxypath is now asynchronous; it is awaited during config initialization (and before channel startup), so ordering relative to network activity is unchanged. A failure to load undici for the proxy dispatcher is now logged instead of thrown from the constructor — the process previously could not reach that state without undici already loaded.Linked Issues
Part of #4748. Implements candidate 4 (lazy undici) from #7264.
中文说明
本 PR 做了什么
把 undici HTTP 客户端移出急切启动闭包。在 telemetry 懒加载工作之后,undici 是 ACP 子进程冷启动中最大的单个第三方开销:两份打包拷贝合计约 2 MiB 的解析/编译成本,每次启动都要支付,而 undici 只在真正发出请求时才被需要——代理 dispatcher、API 预连接、IDE 客户端 fetch、GitHub 配置、自更新。全部 8 个值导入点现在都通过动态 import 加载 undici,收敛到每个包内一个小的单飞 helper。
这个 helper 同时解决了促使它存在的一个打包器互操作陷阱:esbuild 会把 CommonJS 的 undici 包编译成只有 default 导出、没有命名导出的动态 chunk,因此裸写
const { Agent } = await import('undici')在 Node 和 vitest 下正常,在打包后的 CLI 中却解构出undefined——本地测试完全无法发现这种失败。helper 对模块形态做归一化(只在恰好为 default-only 形态时解包);并且刻意在两个包中各保留一份而不是共享,因为两个包各自解析自己的 undici 拷贝,共享 helper 会让另一个包的测试 mock 被静默绕过。两个顺序保证被显式保留:原先在 config 构造期间同步安装的全局代理 dispatcher,现在通过一个被 config 初始化 await 的 promise 安装,保证 dispatcher 一定先于任何网络活动就位;channel 代理路径同样在启动继续之前 await 安装完成。现有 bundle 闭包守卫新增一项检查:任何静态 undici 导入重新进入 ACP 急切闭包都会让 CI 失败。
为什么需要
在本改动之前,Daemon 冷启动(#4748)的每个 ACP 子进程仍要支付 undici 的模块成本。在 2C4G 参考机器上,把它移出急切闭包带来进程到首 session 延迟 P50 −89.5 ms(30 对基准中 30 对全部更快),叠加在 telemetry 懒加载收益之上。急切闭包从 15.42 MiB 缩小到 13.39 MiB,首 session 后常驻内存下降约 8 MB。
评审验证计划
如何验证
npm run build && cross-env DEV=true npm run bundle && node scripts/check-serve-fast-path-bundle.js现在会对 ACP 闭包中的任何静态 undici 导入报错。--proxy或 settings):出站请求仍走代理 dispatcher;dispatcher 在 config 初始化完成前安装到位。channel 代理路径同理。NO_PROXY。node dist/cli.js -p "reply with exactly: ok"完成一次真实模型往返——端到端验证 esbuild default-only chunk 形态。cd packages/core && npx vitest run src/utils/runtimeFetchOptions.test.ts src/utils/runtime-fetch-options.no-proxy.test.ts以及cd packages/cli && npx vitest run src/utils/apiPreconnect.test.ts src/commands/channel/start.test.ts src/services/setup-github.test.ts。证据(前后对比)
N/A(无 UI 变化)。2C4G Linux 机器成对基准,每场景 30 对,control = telemetry 拆分构建,candidate = 本改动:
测试平台
macOS:单元测试、typecheck、lint、bundle 守卫、针对打包 CLI 的真实模型冒烟。Linux(2C4G):针对打包 CLI 的成对冷启动/预热基准。
风险与范围
--proxy路径的 dispatcher 安装现在是异步的;config 初始化(以及 channel 启动前)会 await 它,因此相对网络活动的顺序不变。代理 dispatcher 的 undici 加载失败现在记录日志而非从构造函数抛出——此前进程不可能在 undici 未加载的情况下到达该状态。关联 Issue
属于 #4748 的一部分。实现了 #7264 中的候选项 4(undici 懒加载)。