From 76ce17ced5f46d54ac16ad0627e714d0a8270bf5 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 22 Sep 2026 16:53:04 +0800 Subject: [PATCH] perf(cli): reduce repeated config resolution and startup imports --- bench/config-performance/README.md | 2 +- bench/config-performance/run.ts | 9 +- packages/cli/binding/src/check/mod.rs | 3 + packages/cli/binding/src/cli/execution.rs | 17 ++-- packages/cli/binding/src/cli/handler.rs | 3 +- packages/cli/binding/src/cli/resolver.rs | 83 +++++++++++++++---- .../resolve-universal-vite-config.spec.ts | 74 +++++++++++++++++ packages/cli/src/bin.ts | 8 +- packages/cli/src/resolve-vite-config.ts | 12 ++- 9 files changed, 178 insertions(+), 33 deletions(-) create mode 100644 packages/cli/src/__tests__/resolve-universal-vite-config.spec.ts diff --git a/bench/config-performance/README.md b/bench/config-performance/README.md index b2d4a801fe..8167347b97 100644 --- a/bench/config-performance/README.md +++ b/bench/config-performance/README.md @@ -16,7 +16,7 @@ Use `--samples 2 --warmup 1` for a smoke run. A timing comparison requires at le The cases cover root and package-directory `vp check --fix`, missing and minimal configs, `defineConfig`, root `lint`/`fmt` blocks, standalone tools, and `vp staged` with a check or a no-op task. Each sample starts a fresh process. Cases rotate between rounds. File and Git preparation happen outside the timed interval, and each sample receives the same three unformatted TypeScript files. Staged samples verify the formatted Git index. Command errors and timeouts fail the benchmark. -Config evaluations are measured in separate runs. Synchronous log writes do not affect the timing samples. The current ceilings are four evaluations for a root check, seven for a package check, and five for staged checks. Each Oxc child may evaluate its config at most once. These ceilings retain the current package-directory overhead until a separate optimization reduces it. Reduce the relevant ceiling with that optimization. +Config evaluations are measured in separate runs. Synchronous log writes do not affect the timing samples. The current ceilings are four evaluations for a root or package check and five for staged checks. Each process may evaluate its config at most once. The check process reuses its resolved config when it selects the config for each tool phase. The benchmark uses temporary projects outside the checkout so ancestor config discovery cannot find the repository's config. It runs the same Node executable in staged tasks and tool children. It uses a separate Node compile-cache directory and removes fixtures after completion. Warmups make this a measurement of fresh-process startup with warm filesystem and compile caches, not a cold-disk benchmark. diff --git a/bench/config-performance/run.ts b/bench/config-performance/run.ts index 7615b991c8..06bc38d7be 100644 --- a/bench/config-performance/run.ts +++ b/bench/config-performance/run.ts @@ -73,8 +73,8 @@ const cases = [ package: false, maxLoads: 4, }, - { id: 'package/check/minimal', config: 'minimal', command: 'check', package: true, maxLoads: 7 }, - { id: 'package/check/blocks', config: 'blocks', command: 'check', package: true, maxLoads: 7 }, + { id: 'package/check/minimal', config: 'minimal', command: 'check', package: true, maxLoads: 4 }, + { id: 'package/check/blocks', config: 'blocks', command: 'check', package: true, maxLoads: 4 }, { id: 'root/fmt/minimal', config: 'minimal', command: 'fmt', package: false, maxLoads: 1 }, { id: 'root/lint/minimal', config: 'minimal', command: 'lint', package: false, maxLoads: 1 }, { id: 'root/staged/minimal', config: 'minimal', command: 'staged', package: false, maxLoads: 5 }, @@ -249,10 +249,7 @@ try { } const loads = [...processes.values()]; report.results[index].configLoads = loads; - if ( - entries.length > item.maxLoads || - loads.some(({ role, count }) => count > (role === 'check' && item.package ? 4 : 1)) - ) { + if (entries.length > item.maxLoads || loads.some(({ count }) => count > 1)) { throw new Error(`${item.id}: config evaluation budget exceeded: ${JSON.stringify(loads)}`); } } diff --git a/packages/cli/binding/src/check/mod.rs b/packages/cli/binding/src/check/mod.rs index 3eb6505ff0..f5dce3c214 100644 --- a/packages/cli/binding/src/check/mod.rs +++ b/packages/cli/binding/src/check/mod.rs @@ -84,6 +84,7 @@ pub(crate) async fn execute_check( envs, cwd, false, + &resolved_vite_config, ) .await?; let (fmt_status, combined_output) = combine_output(captured); @@ -177,6 +178,7 @@ pub(crate) async fn execute_check( envs, cwd, true, + &resolved_vite_config, ) .await?; let (lint_status, combined_output) = combine_output(captured); @@ -261,6 +263,7 @@ pub(crate) async fn execute_check( envs, cwd, false, + &resolved_vite_config, ) .await?; let (refmt_status, combined_output) = combine_output(captured); diff --git a/packages/cli/binding/src/cli/execution.rs b/packages/cli/binding/src/cli/execution.rs index 7d4b19991f..8631eb61c5 100644 --- a/packages/cli/binding/src/cli/execution.rs +++ b/packages/cli/binding/src/cli/execution.rs @@ -7,7 +7,10 @@ use vt_path::AbsolutePathBuf; use super::{ resolver::SubcommandResolver, - types::{CapturedCommandOutput, SynthesizableSubcommand, exit_status_from}, + types::{ + CapturedCommandOutput, ResolvedUniversalViteConfig, SynthesizableSubcommand, + exit_status_from, + }, }; /// Resolve a subcommand into a prepared `tokio::process::Command`. @@ -16,8 +19,10 @@ async fn resolve_and_build_command( subcommand: SynthesizableSubcommand, envs: &Arc, Arc>>, cwd: &AbsolutePathBuf, + resolved_config: Option<&ResolvedUniversalViteConfig>, ) -> Result { - let resolved = resolver.resolve(subcommand, envs, cwd).await.map_err(Error::Anyhow)?; + let resolved = + resolver.resolve(subcommand, envs, cwd, resolved_config).await.map_err(Error::Anyhow)?; // Resolve the program path using `which` to handle Windows .cmd/.bat files (PATHEXT) let program_path = { @@ -55,7 +60,7 @@ pub(super) async fn resolve_and_execute( SynthesizableSubcommand::Dev { .. } | SynthesizableSubcommand::Preview { .. } ); - let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd).await?; + let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd, None).await?; // For interactive commands (dev, preview), use terminal guard to restore terminal state on exit let status = if is_interactive { @@ -82,7 +87,7 @@ pub(super) async fn resolve_and_execute_with_filter( stream: FilterStream, filter: impl Fn(&str) -> Cow<'_, str>, ) -> Result { - let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd).await?; + let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd, None).await?; match stream { FilterStream::Stdout => cmd.stdout(Stdio::piped()), FilterStream::Stderr => cmd.stderr(Stdio::piped()), @@ -112,8 +117,10 @@ pub(crate) async fn resolve_and_capture_output( envs: &Arc, Arc>>, cwd: &AbsolutePathBuf, force_color_if_terminal: bool, + resolved_config: &ResolvedUniversalViteConfig, ) -> Result { - let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd).await?; + let mut cmd = + resolve_and_build_command(resolver, subcommand, envs, cwd, Some(resolved_config)).await?; cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); // Capturing output hides the terminal from the child. Preserve colors only when diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs index 494ceb436a..4c569b6393 100644 --- a/packages/cli/binding/src/cli/handler.rs +++ b/packages/cli/binding/src/cli/handler.rs @@ -96,7 +96,8 @@ impl CommandHandler for VitePlusCommandHandler { if super::app_target::needs_elicitation(&subcmd, &command.cwd) { return Ok(HandledCommand::Verbatim); } - let resolved = self.resolver.resolve(subcmd, &command.envs, &command.cwd).await?; + let resolved = + self.resolver.resolve(subcmd, &command.envs, &command.cwd, None).await?; Ok(HandledCommand::Synthesized(resolved.into_synthetic_plan_request())) } CLIArgs::ViteTask(cmd) => Ok(HandledCommand::ViteTaskCommand(cmd)), diff --git a/packages/cli/binding/src/cli/resolver.rs b/packages/cli/binding/src/cli/resolver.rs index 7eccaf25b8..a0c43d723f 100644 --- a/packages/cli/binding/src/cli/resolver.rs +++ b/packages/cli/binding/src/cli/resolver.rs @@ -1,4 +1,4 @@ -use std::{env, ffi::OsStr, iter, sync::Arc}; +use std::{borrow::Cow, env, ffi::OsStr, iter, sync::Arc}; use rustc_hash::FxHashMap; use vt::config::user::{ @@ -62,11 +62,12 @@ impl SubcommandResolver { } /// Resolve root settings for package runs unless the user selected another config. - async fn resolve_workspace_config( + async fn resolve_workspace_config<'a>( &self, cwd: &AbsolutePath, args: &[String], - ) -> anyhow::Result> { + resolved_config: Option<&'a ResolvedUniversalViteConfig>, + ) -> anyhow::Result>> { let explicit_config = args .iter() .take_while(|arg| arg.as_str() != "--") @@ -74,7 +75,12 @@ impl SubcommandResolver { if cwd == self.workspace_path.as_ref() || explicit_config { return Ok(None); } - self.resolve_universal_vite_config().await.map(Some) + // Composite commands can reuse their config within this invocation. + // Do not cache it on the resolver: task runs may observe config changes. + if let Some(config) = resolved_config { + return Ok(Some(Cow::Borrowed(config))); + } + self.resolve_universal_vite_config().await.map(|config| Some(Cow::Owned(config))) } /// Resolve a synthesizable subcommand to a concrete program, args, cache config, and envs. @@ -83,6 +89,7 @@ impl SubcommandResolver { subcommand: SynthesizableSubcommand, envs: &Arc, Arc>>, cwd: &AbsolutePath, + resolved_config: Option<&ResolvedUniversalViteConfig>, ) -> anyhow::Result { match subcommand { SynthesizableSubcommand::Lint { mut args } => { @@ -93,12 +100,13 @@ impl SubcommandResolver { .to_str() .ok_or_else(|| anyhow::anyhow!("lint JS path is not valid UTF-8"))?; - if let Some(config) = self.resolve_workspace_config(cwd, &args).await? + if let Some(config) = + self.resolve_workspace_config(cwd, &args, resolved_config).await? && config.lint.is_some() - && let Some(config_file) = config.config_file + && let Some(config_file) = &config.config_file { args.insert(0, "-c".to_string()); - args.insert(1, config_file); + args.insert(1, config_file.clone()); } Ok(ResolvedSubcommand { @@ -127,12 +135,13 @@ impl SubcommandResolver { .to_str() .ok_or_else(|| anyhow::anyhow!("fmt JS path is not valid UTF-8"))?; - if let Some(config) = self.resolve_workspace_config(cwd, &args).await? + if let Some(config) = + self.resolve_workspace_config(cwd, &args, resolved_config).await? && config.fmt.is_some() - && let Some(config_file) = config.config_file + && let Some(config_file) = &config.config_file { args.insert(0, "-c".to_string()); - args.insert(1, config_file); + args.insert(1, config_file.clone()); } Ok(ResolvedSubcommand { @@ -403,7 +412,7 @@ mod tests { SynthesizableSubcommand::Preview { args: vec![] }, SynthesizableSubcommand::Doc { args: vec![] }, ] { - let resolved = resolver.resolve(command, &envs, &cwd).await.unwrap(); + let resolved = resolver.resolve(command, &envs, &cwd, None).await.unwrap(); assert_eq!(resolved.program, runtime); } } @@ -431,7 +440,7 @@ mod tests { ), (SynthesizableSubcommand::Fmt { args: tool_args }, &["tool.js"]), ] { - let resolved = resolver.resolve(command, &envs, &cwd).await.unwrap(); + let resolved = resolver.resolve(command, &envs, &cwd, None).await.unwrap(); let actual_args: Vec<&str> = resolved.args.iter().map(|arg| arg.as_str()).collect(); let expected_args = [prefix, args].concat(); assert_eq!(actual_args, expected_args); @@ -475,7 +484,7 @@ mod tests { ), (SynthesizableSubcommand::Fmt { args: tool_args }, &["tool.js"], has_fmt), ] { - let resolved = resolver.resolve(command, &envs, &cwd).await.unwrap(); + let resolved = resolver.resolve(command, &envs, &cwd, None).await.unwrap(); let actual_args: Vec<&str> = resolved.args.iter().map(|arg| arg.as_str()).collect(); let mut expected_args = prefix.to_vec(); @@ -489,6 +498,52 @@ mod tests { } } + #[tokio::test] + async fn check_phases_reuse_config_without_caching_later_commands() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let cwd = root.join("packages/app"); + let config_file = root.join("vite.config.ts").as_path().to_str().unwrap().to_string(); + let loads = Arc::new(AtomicUsize::new(0)); + let mut options = cli_options(Arc::from(OsStr::new("node"))); + options.resolve_universal_vite_config = { + let loads = Arc::clone(&loads); + let config_file = config_file.clone(); + Arc::new(move |_| { + // A later command must observe removal of the root tool blocks. + let config = if loads.fetch_add(1, Ordering::SeqCst) == 0 { + serde_json::json!({ "configFile": config_file, "lint": {}, "fmt": {} }) + } else { + serde_json::json!({ "configFile": config_file }) + }; + Box::pin(async move { Ok(config.to_string()) }) + }) + }; + let resolver = SubcommandResolver::new(root.into()).with_cli_options(options); + let envs = Arc::new(FxHashMap::default()); + let config = resolver.resolve_universal_vite_config().await.unwrap(); + + for command in [ + SynthesizableSubcommand::Fmt { args: vec![] }, + SynthesizableSubcommand::Lint { args: vec![] }, + SynthesizableSubcommand::Fmt { args: vec![] }, + ] { + let resolved = resolver.resolve(command, &envs, &cwd, Some(&config)).await.unwrap(); + let args: Vec<&str> = resolved.args.iter().map(|arg| arg.as_str()).collect(); + assert!(args.ends_with(&["-c", &config_file])); + } + assert_eq!(loads.load(Ordering::SeqCst), 1); + + let resolved = resolver + .resolve(SynthesizableSubcommand::Fmt { args: vec![] }, &envs, &cwd, None) + .await + .unwrap(); + assert_eq!(loads.load(Ordering::SeqCst), 2); + assert_eq!(resolved.args.as_ref(), &[Str::from("tool.js")]); + } + #[tokio::test] async fn explicit_lint_and_fmt_config_from_subdirectory_skips_root_config_loading() { let temp = tempfile::tempdir().unwrap(); @@ -513,7 +568,7 @@ mod tests { ), (SynthesizableSubcommand::Fmt { args: tool_args }, &["tool.js"]), ] { - let resolved = resolver.resolve(command, &envs, &cwd).await.unwrap(); + let resolved = resolver.resolve(command, &envs, &cwd, None).await.unwrap(); let actual_args: Vec<&str> = resolved.args.iter().map(|arg| arg.as_str()).collect(); let expected_args = [prefix, args].concat(); assert_eq!(actual_args, expected_args); diff --git a/packages/cli/src/__tests__/resolve-universal-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-universal-vite-config.spec.ts new file mode 100644 index 0000000000..c5c53f80ee --- /dev/null +++ b/packages/cli/src/__tests__/resolve-universal-vite-config.spec.ts @@ -0,0 +1,74 @@ +import fs from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; + +import { resolveUniversalViteConfig } from '../resolve-vite-config.ts'; +import { VITE_CONFIG_FILES } from '../utils/constants.ts'; + +describe('resolveUniversalViteConfig', () => { + let root: string; + + beforeEach(() => { + root = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'vp-universal-config-'))); + fs.writeFileSync(path.join(root, 'package.json'), '{"type":"module"}'); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('returns empty metadata without searching ancestors and observes a new config', async () => { + const cwd = path.join(root, 'app'); + fs.mkdirSync(cwd); + fs.writeFileSync(path.join(root, 'vite.config.ts'), 'throw new Error("outside workspace");'); + expect(JSON.parse(await resolveUniversalViteConfig(null, cwd))).toEqual({}); + + const configFile = path.join(cwd, 'vite.config.ts'); + fs.writeFileSync(configFile, 'export default { check: { fmt: false } };'); + expect(JSON.parse(await resolveUniversalViteConfig(null, cwd))).toMatchObject({ + configFile: configFile.replaceAll('\\', '/'), + check: { fmt: false }, + }); + fs.unlinkSync(configFile); + expect(JSON.parse(await resolveUniversalViteConfig(null, cwd))).toEqual({}); + }); + + it.each(VITE_CONFIG_FILES)( + 'resolves %s with config functions and plugin hooks', + async (filename) => { + const configFile = path.join(root, filename); + const declaration = + filename.endsWith('.cjs') || filename.endsWith('.cts') + ? 'module.exports =' + : 'export default'; + fs.writeFileSync( + configFile, + `${declaration} async ({ command, mode }) => ({ + lint: {}, fmt: {}, run: {}, staged: { '*.ts': 'vp check --fix' }, + plugins: [{ name: 'metadata', config() { + return { check: { fmt: command !== 'build', lint: mode !== 'development' } }; + } }], + });`, + ); + expect(JSON.parse(await resolveUniversalViteConfig(null, root))).toEqual({ + configFile: configFile.replaceAll('\\', '/'), + lint: {}, + fmt: {}, + run: {}, + staged: { '*.ts': 'vp check --fix' }, + check: { fmt: false, lint: false }, + }); + }, + ); + + it('preserves callback and config errors', async () => { + const error = new Error('callback failed'); + await expect(resolveUniversalViteConfig(error, root)).rejects.toBe(error); + vi.spyOn(console, 'error').mockImplementation(() => {}); + fs.writeFileSync(path.join(root, 'vite.config.ts'), 'throw new Error("invalid config");'); + await expect(resolveUniversalViteConfig(null, root)).rejects.toThrow('invalid config'); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 38484ab1d4..4bef40cef1 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -129,9 +129,6 @@ if (maybePrintCommandHelp(args)) { } else { // All other commands — delegate to Rust core via NAPI binding try { - // This module imports `vitest/config` through define-config. Load it here - // so `migrate` and `config` can handle stale aliases before Vitest loads. - const { resolveUniversalViteConfig } = await import('./resolve-vite-config.js'); const initInspection = inspectInitCommand(command, args.slice(1)); if ( initInspection.handled && @@ -155,7 +152,10 @@ if (maybePrintCommandHelp(args)) { doc, toolchainManifestPath: path.join(cliDistDir, 'toolchain.json'), vitePlusPackagePath, - resolveUniversalViteConfig, + async resolveUniversalViteConfig(err, cwd) { + const { resolveUniversalViteConfig } = await import('./resolve-vite-config.js'); + return resolveUniversalViteConfig(err, cwd); + }, explicitChdir, args: rustCliArgs, }); diff --git a/packages/cli/src/resolve-vite-config.ts b/packages/cli/src/resolve-vite-config.ts index 0632f109b1..45b3b7ae67 100644 --- a/packages/cli/src/resolve-vite-config.ts +++ b/packages/cli/src/resolve-vite-config.ts @@ -1,7 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { withConfigMetadataResolution } from './define-config.ts'; import { VITE_CONFIG_FILES } from './utils/constants.ts'; /** @@ -83,7 +82,11 @@ export interface ResolveViteConfigOptions { * Resolve vite.config.ts and return the config object. */ export async function resolveViteConfig(cwd: string, options?: ResolveViteConfigOptions) { - const { resolveConfig } = await import('./index.js'); + // Discovery helpers and commands without a config must not load Vite/Vitest. + const [{ resolveConfig }, { withConfigMetadataResolution }] = await Promise.all([ + import('./index.js'), + import('./define-config.ts'), + ]); // This loads the config purely to read a non-plugin block (lint/fmt/pack/run/ // staged/create…), so skip the user's plugin factory while it evaluates. @@ -107,6 +110,11 @@ export async function resolveUniversalViteConfig(err: null | Error, viteConfigCw throw err; } try { + // Rust already supplies the workspace root. Without a config there are no + // user metadata blocks, so there is no need to initialize Vite. + if (!hasViteConfig(viteConfigCwd)) { + return '{}'; + } const config = await resolveViteConfig(viteConfigCwd); return JSON.stringify({