Repository navigation
perf(cli): stop one-shot runs from booting the CLI twice - #12602
Conversation
A one-shot prompt re-executes itself to apply Node memory arguments, but with advanced.autoConfigureMemory off (the default, and always under --bare) there are none: execve boots an identical image a second time. Continue in place when the replacement would add no Node or script arguments and no Node boot variable (e.g. NODE_EXTRA_CA_CERTS from a home .env) changed after startup. The launcher's spawn path also never enabled the compile cache, so every spawned run recompiled the bundle; pass NODE_COMPILE_CACHE to the child the way the serve fast path already does.
Every import of scripts/cli-entry.js stamps QWEN_CODE_MANAGED_NPM_PIN and QWEN_CODE_MANAGED_NPM_ROOT into the real process.env, and the pin's bootstrap guard cannot tell two imports of the same file apart: Vitest drops the cache-buster query from import.meta.url, so currentEntryPath is identical across tests. The compile-cache tests therefore handed their pin to the tilde-QWEN_HOME test below them, which then read the inherited updateRoot instead of the tmpdir fallback it pins. Previously harmless only by accident — the standalone-shim test above them deleted both variables. Clear both in beforeEach so every test drives a top-level invocation. The assertion is unchanged, and breaking the homedir fallback in getHomeDir still fails it (checked by mutation). Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmuf135w40z
Brings in the base-side .github/workflows/ci.yml change (a5a5eb5) so the "Check lint gate freshness" step sees a current GATE_FILE copy. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmuf135w40z
|
@qwen-code /triage |
Continuing in place skipped the second boot that let module-level env reads (e.g. DASHSCOPE_PROXY_BASE_URL in the DashScope provider) see values from .env or settings.env. Guard on any injected value instead of only NODE_*/UV_ keys; that also covers NODE_EXTRA_CA_CERTS.
utils/ may not value-import from config/ (lint boundary), so llm.tsx computes hasLoadedEnvironmentValues() and hands it to relaunchAppInChildProcess.
yiliang114
left a comment
There was a problem hiding this comment.
Review at 5019fa2 (author is myself — this COMMENT is the record; a non-author maintainer vote is needed to clear REVIEW_REQUIRED).
No blocking findings. All 7 open threads are the bot's Suggestion-level notes from today, and I verified each against the head rather than adopting them:
- R1-1 / R1-8 (test-worthiness): real — the
environmentChangedSinceBootwiring has no test witness, and the disabled-cache test never exercises thestatus === ENABLEDterm. Worth one small test each in a follow-up. - R1-2 (hasLoadedEnvironmentValues under-reports): real but narrow — only when
CLOUD_SHELL=trueAND a.envcarries the exact keys the cloud-shell helper writes first (e.g.GOOGLE_CLOUD_PROJECT). The relaunch skip then misses a file-injected value. Edge enough to fix in a follow-up rather than gate this PR. - R1-3 / R1-4 (guard duplicated; design-doc invariant now stale): real — three copies of the compile-cache publish guard exist now, and
acp-compile-cache-propagation.md's "only mutation point" line is falsified by the second launcher site. Cheap follow-up: hoist the guard + fix the doc sentence. - R1-5 (identical-image skip inside the execve gate): noted — win32/os400 don't reach
execveat all, so moving the skip platform-wide needs a separate look at what relaunch means there; not a defect in this PR's scope. - R1-6 (launcher writes resolved NODE_COMPILE_CACHE into the child env): real semantic note — a file-sourced
NODE_COMPILE_CACHEcan no longer win in the child because the key arrives pre-set. The written value is the correct default, and overriding it via.envis a rare corner; acceptable for this PR, worth a doc line.
CI is green except review-pr (queued, bot infrastructure). Threads stay open for author disposition rather than resolved-by-me.
chiga0
left a comment
There was a problem hiding this comment.
No blocking findings.
Approval blockers: none.
Tier: Standard — perf optimization, in-place continuation skips the second CLI boot when a relaunch would produce an identical image.
Checked:
- Tracking correctness:
dotEnvSourcedKeysandsettingsEnvSourcedKeysare populated insideloadEnvironment()only when a key is actually written toprocess.env(behindisEffectivelyUnset).hasLoadedEnvironmentValues()correctly returnstruewhenever any value was injected by.envorsettings.env.preResolveHomeEnvOverridesintentionally does not contribute to these sets — HOME bootstrap vars are set before any module loads, so the continuing process already holds the right state and a fresh image would read the same values. - In-place condition (
relaunch.ts:99–106): fires only whenadditionalNodeArgs.length === 0 && additionalScriptArgs.length === 0 && !options?.environmentChangedSinceBoot— the three conditions required for a truly identical image.QWEN_CODE_NO_RELAUNCHis set totrueon in-place continuation, matching what the replaced image would have received.childEnventries are deliberately not applied (correct: provenance info is already in-memory;privateAcpChildEnvis only relevant whenreplaceProcessis false, i.e. not on this path). - Fallback-to-spawn test (
relaunch.test.ts:270–279): correctly updated to use['--max-old-space-size=4096']so the execve path is exercised rather than the new in-place short-circuit. - Compile-cache forwarding (
cli-entry.js):module.enableCompileCache?.()with three guards — not already inherited (!env['NODE_COMPILE_CACHE']), status isENABLED, directory is present. Tests cover the newly-enabled case, an inheritedNODE_COMPILE_CACHE, andNODE_DISABLE_COMPILE_CACHE(mock returns{ status: 3 }, no directory set). - Design doc (EN + zh-CN): in-place continuation paragraph added at matching positions in both files.
Suggestions noted (non-blocking, consistent with prior reviewer's Suggestion classification):
- R1-2:
hasLoadedEnvironmentValues()under-reports on Cloud Shell:setUpCloudShellEnvironmentInEnv(environment.ts:682) writesGOOGLE_CLOUD_PROJECTbefore the tracking loop, so a.envsupplying only that key returnsfalse. No import-time read of that key was identified, so no live wrong outcome — but the JSDoc's completeness claim is violated. - R1-5: In-place skip sits inside the
execve/win32 gate; the new design-doc paragraph says "every one-shot run" but Windows / IBM i don't benefit. Doc overclaims. - R1-6: Launcher writes final
NODE_COMPILE_CACHEinto the spawned child's boot env, pre-empting the CLI precedence layer. An operator'ssettings.envcompile-cache path is silently overridden on spawned runs, though honoured on theservefast path. Author acknowledged as a documented gap.
Cross-check: Author and qqqys both classified all seven prior suggestions as non-blocking. I agree with that classification.
Not covered: macOS and Windows end-to-end timing (no host); rung 3 not applicable (no terminal-dependent behavior changed).
Reviewed with AI assistance.
qqqys
left a comment
There was a problem hiding this comment.
Critical-only review at 5019fa27, base d0cd622a.
Verdict: APPROVE — no Critical. Seven Suggestions are unresolved and I do not treat any of them as gating; two of them point at claims I checked independently because they sit on the wire that makes the skip safe, and both hold.
The skip is gated on the three things that would make a fresh image necessary
if (
additionalNodeArgs.length === 0 &&
additionalScriptArgs.length === 0 &&
!options?.environmentChangedSinceBoot
) {
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
return;
}A replacement image is only needed when the new process would differ from this one, and the guard tests exactly the three ways it could: new Node arguments (the caller passes memoryArgs, so a heap-calibrated run still execs), new script arguments, and an environment that changed after this process's modules and Node itself read it. QWEN_CODE_NO_RELAUNCH is not a new signal — llm.tsx already reads it at lines 188 and 522, the latter alongside SANDBOX — so publishing it here puts this path on the same footing as the ones that already skip relaunching.
The predicate under-reports, and that is safe — verified rather than assumed
One unresolved thread says hasLoadedEnvironmentValues() contradicts its own JSDoc, because a .env-supplied value can reach process.env without entering either tracking set. That is factually correct, so I checked whether any of those untracked values is one that requires a fresh image — which is the only way under-reporting could cost something.
The tracked tiers are the two that matter for this question: the .env tier at lines 694-703 and the settings.env tier at 732-740 both apply the no-override rule and both add the key to their set, so anything they inject is reported. The untracked paths are two, and neither needs a relaunch:
readHomeEnvInto, reached frompreResolveHomeEnvOverrides, injects the home bootstrap keys — and its entire purpose, per its own docblock, is to resolveQWEN_HOMEandQWEN_RUNTIME_DIRbefore any settings or storage path is read, precisely so the current process does not snapshot legacy paths. The values are consumed in-process, by design, ahead of the readers. A fresh image would add nothing.setUpCloudShellEnvironmentwritesGOOGLE_CLOUD_PROJECTin place. It is read at runtime by application code, not by Node at startup, so this process already has it.
Neither is a variable Node itself reads during bootstrap — NODE_EXTRA_CA_CERTS is the shape that would matter, and that arrives through the tracked tiers. So the JSDoc over-claims its own scope, which is worth tightening, and the behaviour is correct.
The related thread about the escape hatch having no test is also fair: environmentChangedSinceBoot: hasLoadedEnvironmentValues() at the single production call site, and the predicate it calls, are asserted nowhere, so deleting the line would keep every suite green while silently reintroducing the relaunch for env-changed runs. I confirmed the line is present at this head and that the predicate answers correctly for the tiers that matter, so the uncovered path contains no defect today — which under a Critical-only standard makes it a coverage gap rather than a blocker. It is the one test I would add first.
Skipping the execve does not strand the afterSpawn hook
The caller passes afterSpawn: clearCorruptionEnvVars, which deletes ENV_CORRUPTED_PATH and ENV_WAS_RECOVERED. On the skip path that hook never runs, so I checked whether the markers ending up retained is wrong. It is not: in the relaunch path the child inherits them and the parent clears them after spawning, so the process doing the real work holds them in both cases. Continuing in place makes this process that process. The outcomes match.
The compile-cache addition is correct, with two recorded costs
The launcher's non-fast-path branch now enables the compile cache and writes the resolved directory into the child's boot environment, guarded on !env['NODE_COMPILE_CACHE'], status === ENABLED and a non-empty directory — so it cannot publish a half-initialised value, and it respects NODE_DISABLE_COMPILE_CACHE because enableCompileCache() returns a non-ENABLED status in that case. Two unresolved threads about it are accurate and neither is blocking: the guard is a near-duplicate of the one already in this file's in-process fast path, differing in three of ten lines; and the design doc's recorded invariant that the production fast path is the only mutation point is now false, so acp-compile-cache-propagation.md:66 needs updating. The precedence thread is real too — because loadEnvironment is no-override for both tiers, a NODE_COMPILE_CACHE supplied by a .env file can no longer win over the launcher's resolved directory. That is a user-visible precedence change on a regenerable performance knob, not a correctness or data-loss defect, so it does not gate this merge, but it is the one I would not leave undocumented.
The last thread notes that the identical-image skip sits inside the execve / non-win32 / non-os400 gate while the added design-doc text speaks of every one-shot run, so Windows still double-boots. Worth aligning the doc to the gate; the gate itself is the right place for the decision, since execve is what makes continuing in place equivalent.
CI
15 checks pass and 27 are skipped at this head, with route still pending and nothing failing. Test (ubuntu-latest, Node 22.x), Lint & Static and the cli-entry suite that covers the launcher all completed green. No review carries a changes-requested state.
Resolve the launcher conflict with QwenLM#12602 by enabling the compile cache once, before the platform split. The Windows child gets NODE_COMPILE_CACHE in its environment as on main; on POSIX the in-process CLI exports it, so supervised relaunches and tool subprocesses still receive it. The QwenLM#12603 theme-baseline test stopped main() at the supervised relaunch; a plain one-shot run now supervises itself in-process, so the test stops there too.
QwenLM#12602 re-boots a one-shot run whenever .env or settings.env injected values, because modules that read the environment at load time (and Node itself, e.g. NODE_EXTRA_CA_CERTS) only see them in a fresh image. The in-process path ran ahead of that check and skipped it for both interactive and -p. Take the in-process path only when no env-file value was injected; otherwise relaunch as main does.
…ntials (QwenLM#12680) * perf(cli): keep running in place when env files only hold model credentials The env-file relaunch rule from QwenLM#12602 fires for any value that `.env` files or `settings.env` inject, including the API key that `/auth` stores and the keys the docs recommend for `~/.qwen/.env`. Those users always paid the second boot. Providers read credentials and endpoints per request, never while a module loads, so `hasLoadedEnvironmentValues()` now ignores the documented per-auth-type variables and the `envKey` of every configured model provider. Any other injected value still forces a fresh image, and a provider entry can never exempt a loader or boot-time key. The DashScope proxy base URL was the one such variable read at module load; it is now read at call time. Reported by @wenshao in the QwenLM#12622 review (F2). Claude-Session: https://claude.ai/code/session_01163b7X4Ntw1S2pr9VrtRrs * feat(scripts): let the startup benchmark load credentials from a file `--credentials settings|dotenv` moves the stub's key and URL from the shell into the settings file's `env` block or `~/.qwen/.env`, so the env-file relaunch rule can be measured. The default stays `shell`. Claude-Session: https://claude.ai/code/session_01163b7X4Ntw1S2pr9VrtRrs
…wenLM#12622) * perf(cli): halve fresh-startup time to typeable and cut RSS by 60% Measured with a clean-environment benchmark following the approach in https://claude.dev/blog/how-we-made-claude-ai-faster/ (interleaved A/B, p75 wall-clock backed by deterministic proxies). Against main, time to a typeable prompt goes from 2.55 s to 1.18 s, `qwen -p` time to first request from 2.15 s to 0.94 s, and RSS at the prompt from 574 MB to 231 MB, with no screen change after the prompt becomes usable. - Skip the supervisor relaunch when it adds no Node flags; restarts re-exec in place and updates run in-process (POSIX, plain TUI and -p). - The bin launcher runs the CLI in-process on POSIX. - Enable Node's compile cache on the default route. - Move startup side effects off the critical path: editor probing, the IDE process walk outside VS Code, and the npm-based update check. - Resolve the git branch before the first frame so the footer does not reflow the screen after the prompt is usable. - Bundle with minifyWhitespace; load the review command and non-common highlight grammars only when needed. Experiments, tradeoffs and open decisions are in docs/design/2026-09-24-fresh-startup-performance.md. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01N1XQv6wm9YJ5rSM53eMSRT * fix(cli): keep global.gc in relaunched sessions On POSIX the bin launcher now runs the CLI in-process and exposes gc at runtime, so --expose-gc is no longer in process.execArgv. The supervised relaunch built its child's flags from execArgv alone, so the session process lost global.gc in --acp, stream-json, --json-fd, file input, advanced.autoConfigureMemory, and on Node without process.execve. Both relaunch paths now take their flags from one helper that adds --expose-gc when gc is exposed but not on the command line. * fix(cli): relaunch when env files injected values QwenLM#12602 re-boots a one-shot run whenever .env or settings.env injected values, because modules that read the environment at load time (and Node itself, e.g. NODE_EXTRA_CA_CERTS) only see them in a fresh image. The in-process path ran ahead of that check and skipped it for both interactive and -p. Take the in-process path only when no env-file value was injected; otherwise relaunch as main does. * fix(cli): keep the spawned CLI under Bun The Bun standalone flavor (the OpenTUI preview archives) runs the same bin entry. Bun has no v8.setFlagsFromString, so the in-process path crashed every non-fast-path command at startup on macOS and Linux. Under Bun the entry spawns the CLI with --expose-gc, as on Windows. Also correct the entry's header comment, the relaunch-flags helper's doc comment and a displaced test comment, and narrow the design doc's gc claim to the modes that were checked. * fix(cli): install a deferred update after a clean exit Without a supervising parent, the startup check fell back to "Run /update to install the update." for standalone and update-command installs. Record the request in-process instead: once the session ends cleanly, install the update and exit with its code, so the launcher's exit hook reopens the new version as the parent did on main. A second quit during the install waits instead of cutting it short. * docs(design): record the fresh-startup decisions Resolve the open decisions, and describe the env-file relaunch rule, the Bun launcher path, the restored update-on-exit and the independent reproductions, in both language versions. * docs(design): record decision 7 as a manual harness without a CI gate The follow-up (QwenLM#12674) checks in the benchmark harness for manual runs. The interactive startup counts stop at the typeable prompt, and timer-driven work before it shifts with runner speed, so a CI gate would be flaky. --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Shaojin Wen <[email protected]>
What this PR does
When an explicit one-shot prompt would relaunch itself without adding any Node or script arguments, and neither
.envfiles norsettings.envinjected any value, the CLI now keeps running in the current process instead of callingexecve. If they did inject something, the process is still replaced: modules that read the environment when they load (e.g. the DashScope provider'sDASHSCOPE_PROXY_BASE_URL) and Node itself (NODE_EXTRA_CA_CERTS) only see those values in a fresh image. The production launcher also passesNODE_COMPILE_CACHEto the CLI it spawns, so spawned runs reuse compiled code the way the in-processserve/mcpfast path already does. An inheritedNODE_COMPILE_CACHEandNODE_DISABLE_COMPILE_CACHEare both respected. The one-shot design doc (EN + zh-CN) documents the in-place continuation.Why it's needed
advanced.autoConfigureMemorydefaults to off, and--barenever loads it, so the memory relaunch normally has no arguments to add. Replacing the process then only boots an identical image a second time: every module loads again, settings load again, startup probes run again. Separately, the spawn path never enabled the compile cache, so every one-shot run recompiled the bundle. Between them, these are the largest share of the startup, CPU and memory cost tracked in #12405.Reviewer Test Plan
How to verify
qwen --bare --safe-mode --output-format json -p hiagainst any OpenAI-compatible endpoint understrace -f -e trace=execve(Linux) orNODE_DEBUG=child_process. Expected: only twonodeimages (launcher + CLI), not three.NODE_EXTRA_CA_CERTS=<path>in~/.qwen/.envand repeat. Expected: threenodeimages, because the process is still replaced so the new CA setting reaches Node.$TMPDIR/node-compile-cacheby default) now exists.packages/cli/src/utils/relaunch.test.ts(continues in place, still replaces the process when.env/settings.envinjected values, falls back to spawn whenexecvefails) andscripts/tests/cli-entry.test.js(the compile cache is handed to the child; inherited and disabled caches are respected).Evidence (Before & After)
Measured on Linux x64, 4 vCPU, Node 22.23.2, published
@qwen-code/[email protected], local OpenAI-compatible mock,--bare --safe-mode --no-telemetry --output-format json -p hi, mean of 3–4 warm runs. To produce the "after" rows, the same two changes were applied to that installed bundle:Time to the first model request dropped from ~3.85 s to ~1.95 s. The
NODE_EXTRA_CA_CERTS-in-.envcase still produces 3 images.Tested on
Windows has no
execveand keeps the supervised relaunch unchanged; the compile-cache part of the change is platform-neutral.Environment (optional)
Published 0.24.4 bundle with the equivalent patch applied, local mock server. Unit tests were written but not run locally; CI runs them.
Risk & Scope
.envorsettings.envsets variables (commonly API keys in~/.qwen/.env) still pay the second boot; the gain applies when configuration comes from the process environment, as in automation and the benchmark. The in-place path depends on the fact that the relaunched child would have seen exactly this process's state. The only extra thing it used to receive is the relaunch env-provenance marker. This process already holds that state in memory, so the marker is not copied into its own environment, where it would reach tool subprocesses.NODE_COMPILE_CACHEnow reaches tool subprocesses too, the same way it already does from theservefast path.Config.initialize()(perf(cli): headless startup runs interactive-only probes (IDE process walk, macOS theme) #12599); the resident launcher process.Design: English · 简体中文
Linked Issues
Closes #12598. Part of #12405.
中文说明
本 PR 的改动
显式 one-shot prompt 要自我 relaunch 时,如果不会多出任何 Node 参数或脚本参数,并且
.env文件和settings.env都没有注入任何值,CLI 现在直接在当前进程里继续运行,不再调用execve。只要注入过值,就仍然替换进程:在加载时读取环境变量的模块(比如 DashScope provider 的DASHSCOPE_PROXY_BASE_URL)和 Node 本身(NODE_EXTRA_CA_CERTS),只有在新镜像里才能看到这些值。生产 launcher 现在也把NODE_COMPILE_CACHE传给它 spawn 的 CLI,让 spawn 出来的运行也复用编译结果,与进程内serve/mcpfast path 一致。继承下来的NODE_COMPILE_CACHE和NODE_DISABLE_COMPILE_CACHE都会被尊重。one-shot 设计文档(中英文)补充了原地继续运行的说明。为什么需要这个改动
advanced.autoConfigureMemory默认关闭,--bare也不会加载它,所以内存 relaunch 通常没有参数可加。这时替换进程只是把同样的镜像再启动一遍:所有模块重新加载,settings 重新读取,启动探测再跑一遍。另外,spawn 路径从来没开编译缓存,每次 one-shot 都要重新编译整个 bundle。这两点合起来,是 #12405 所跟踪的启动时间、CPU 和内存开销的最大头。Reviewer 测试计划
如何验证
qwen --bare --safe-mode --output-format json -p hi,同时挂strace -f -e trace=execve(Linux)或NODE_DEBUG=child_process。预期:只有两个node镜像(launcher + CLI),不再是三个。~/.qwen/.env里写入NODE_EXTRA_CA_CERTS=<path>后重复。预期:三个node镜像,因为仍会替换进程,让新的 CA 设置传到 Node。$TMPDIR/node-compile-cache)已经生成。packages/cli/src/utils/relaunch.test.ts(原地继续运行;.env/settings.env注入过值时仍替换进程;execve失败时回退到 spawn)和scripts/tests/cli-entry.test.js(编译缓存传给子进程;继承的缓存和禁用的缓存都被尊重)。证据(改动前后)
测量环境:Linux x64、4 vCPU、Node 22.23.2、已发布的
@qwen-code/[email protected]、本地 OpenAI 兼容 mock,参数--bare --safe-mode --no-telemetry --output-format json -p hi,取 3–4 次热启动的平均值。“改动后”一行的数据来自把同样两处改动打进那份已安装的 bundle:到第一个模型请求的时间从 ~3.85 s 降到 ~1.95 s。
.env里带NODE_EXTRA_CA_CERTS的情况仍是 3 个镜像。已测试平台
Windows 没有
execve,受监督的 relaunch 保持不变;编译缓存这部分改动与平台无关。环境(可选)
已发布的 0.24.4 bundle 打上等价补丁,本地 mock server。单测已编写,但没有在本地跑,交给 CI。
风险与范围
.env或settings.env里设了变量的用户(常见的是~/.qwen/.env里放 API key)仍然要启动两遍;收益主要落在配置来自进程环境变量的场景,比如自动化和 benchmark。原地继续运行成立的前提是,relaunch 出来的子进程看到的状态本来就和当前进程完全一样。它额外收到的只有 relaunch 的环境来源标记。当前进程内存里已经有这份状态,所以不把这个标记写回自己的环境变量,否则它会流到工具子进程里。NODE_COMPILE_CACHE现在也会流到工具子进程,与servefast path 现有的行为一致。Config.initialize()~1 s 空等(perf(cli): headless startup runs interactive-only probes (IDE process walk, macOS theme) #12599);常驻的 launcher 进程。设计文档:English · 简体中文
关联 Issue
Closes #12598。属于 #12405。