From 80e7bfa04cbba4670f696913873bb4908a5ff88d Mon Sep 17 00:00:00 2001 From: Liang Date: Sat, 8 Aug 2026 22:12:48 +0800 Subject: [PATCH 1/6] fix(cli): run upgrade checks in shell background --- crates/vp_global_cli/Cargo.toml | 2 +- crates/vp_global_cli/src/cli.rs | 16 +- .../vp_global_cli/src/commands/env/setup.rs | 40 +++ .../vp_global_cli/src/commands/upgrade/mod.rs | 7 + crates/vp_global_cli/src/main.rs | 17 +- crates/vp_global_cli/src/upgrade_check.rs | 274 ++++++++++++------ rfcs/upgrade-check.md | 115 ++++---- 7 files changed, 314 insertions(+), 157 deletions(-) diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index 20e8275562..067784e53b 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -24,6 +24,7 @@ serde_json = { workspace = true } node-semver = { workspace = true } thiserror = { workspace = true } tar = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } owo-colors = { workspace = true } @@ -44,7 +45,6 @@ uuid = { workspace = true, features = ["v4"] } [dev-dependencies] serial_test = { workspace = true } -tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index ce60057396..c248c7756a 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -230,6 +230,10 @@ pub enum Commands { /// Custom npm registry URL #[arg(long)] registry: Option, + + /// Refresh the cached update status without producing output + #[arg(long, hide = true)] + background_check: bool, }, /// Remove vp and all related data @@ -1036,7 +1040,16 @@ pub async fn run_command_with_options( Commands::Env(args) => commands::env::execute(cwd, args).await, // Self-Management - Commands::Upgrade { version, tag, check, rollback, force, silent, registry } => { + Commands::Upgrade { + version, + tag, + check, + rollback, + force, + silent, + registry, + background_check, + } => { commands::upgrade::execute(commands::upgrade::UpgradeOptions { version, tag, @@ -1045,6 +1058,7 @@ pub async fn run_command_with_options( force, silent, registry, + background_check, }) .await } diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 7baee7c074..d8bb6bd4d3 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -537,9 +537,21 @@ case ":${PATH}:" in esac unset __vp_bin +# Start the Rust update checker as a detached shell job. The checker itself +# uses a cross-process lock and returns immediately when the cache is fresh. +__vp_background_upgrade_check() { + case $- in *i*) ;; *) return 0 ;; esac + ( + command vp upgrade --background-check /dev/null 2>&1 & + disown 2>/dev/null || true + ) +} +__vp_background_upgrade_check + # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. vp() { + __vp_background_upgrade_check if [ "$1" = "env" ] && [ "$2" = "use" ]; then case " $* " in *" -h "*|*" --help "*) command vp "$@"; return; esac __vp_out="$(VP_ENV_USE_EVAL_ENABLE=1 VP_SHELL=sh command vp "$@")" || return $? @@ -572,9 +584,17 @@ set -l __vp_idx (contains -i -- __VP_BIN__ $PATH) and set -e PATH[$__vp_idx] set -gx PATH __VP_BIN__ $PATH +function __vp_background_upgrade_check + status is-interactive; or return + command vp upgrade --background-check /dev/null 2>&1 & + disown 2>/dev/null +end +__vp_background_upgrade_check + # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. function vp + __vp_background_upgrade_check if test (count $argv) -ge 2; and test "$argv[1]" = "env"; and test "$argv[2]" = "use" if contains -- -h $argv; or contains -- --help $argv command vp $argv; return @@ -606,9 +626,17 @@ const ENV_TEMPLATE_NU: &str = r#"# Vite+ environment setup (https://viteplus.dev $env.VP_HOME = ("__VP_HOME__" | path expand --no-symlink) $env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") +def __vp_background_upgrade_check [] { + if $nu.is-interactive { + job spawn { ^vp upgrade --background-check | complete | ignore } | ignore + } +} +__vp_background_upgrade_check + # Shell function wrapper: intercepts `vp env use` to parse its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. def --env --wrapped vp [...args: string@"nu-complete vp"] { + __vp_background_upgrade_check if ($args | length) >= 2 and $args.0 == "env" and $args.1 == "use" { if ("-h" in $args) or ("--help" in $args) { ^vp ...$args @@ -670,9 +698,21 @@ if ($env:Path -split ';' -notcontains $__vp_bin) { $env:Path = "$__vp_bin;$env:Path" } +function __vp_background_upgrade_check { + if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { return } + $__vp_args = [Environment]::GetCommandLineArgs() + if ($__vp_args -match '^-(NonInteractive|noni|File|f|Command(WithArgs)?|c(wa)?|EncodedCommand|e(c)?)$' -or $__vp_args -match '\.ps1$') { return } + try { + $__vp_null = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { "NUL" } else { "/dev/null" } + Start-Process -FilePath (Join-Path $__vp_bin "vp") -ArgumentList "upgrade", "--background-check" -NoNewWindow -RedirectStandardError $__vp_null -ErrorAction SilentlyContinue | Out-Null + } catch {} +} +__vp_background_upgrade_check + # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. function vp { + __vp_background_upgrade_check if ($args.Count -ge 2 -and $args[0] -eq "env" -and $args[1] -eq "use") { if ($args -contains "-h" -or $args -contains "--help") { & (Join-Path $__vp_bin "vp") @args; return diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index c853e84881..f8dce23e64 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -29,11 +29,18 @@ pub struct UpgradeOptions { pub silent: bool, /// Custom npm registry URL pub registry: Option, + /// Refresh cached update status for shell integrations + pub background_check: bool, } /// Execute the upgrade command. #[allow(clippy::print_stdout, clippy::print_stderr)] pub async fn execute(options: UpgradeOptions) -> Result { + if options.background_check { + crate::upgrade_check::run_background_check().await; + return Ok(ExitStatus::default()); + } + let install_dir = get_vp_home()?; // Handle --rollback diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index 14deffa4e1..a62e8f5ad5 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -436,13 +436,8 @@ async fn main() -> ExitCode { // Parse CLI arguments (using custom help formatting) let parse_result = try_parse_args_from(normalized_args); - // Spawn background upgrade check for eligible commands - let upgrade_handle = match &parse_result { - Ok(args) if upgrade_check::should_run_for_command(args) => { - Some(tokio::spawn(upgrade_check::check_for_update())) - } - _ => None, - }; + let should_display_upgrade_notice = + parse_result.as_ref().is_ok_and(upgrade_check::should_display_for_command); let exit_code = match parse_result { Err(e) => { @@ -510,12 +505,8 @@ async fn main() -> ExitCode { }, }; - // Display upgrade notice if a newer version is available - if let Some(handle) = upgrade_handle - && let Ok(Ok(Some(result))) = - tokio::time::timeout(std::time::Duration::from_millis(500), handle).await - { - upgrade_check::display_upgrade_notice(&result); + if should_display_upgrade_notice { + upgrade_check::display_cached_upgrade_notice(); } exit_code diff --git a/crates/vp_global_cli/src/upgrade_check.rs b/crates/vp_global_cli/src/upgrade_check.rs index 6cd8826d18..2cff31b644 100644 --- a/crates/vp_global_cli/src/upgrade_check.rs +++ b/crates/vp_global_cli/src/upgrade_check.rs @@ -1,68 +1,160 @@ -//! Background upgrade check for the vp CLI. +//! Background upgrade check state for the vp CLI. //! -//! Periodically queries the npm registry for the latest version and caches the -//! result to `~/.vite-plus/.upgrade-check.json`. Displays a one-line notice on -//! stderr when a newer version is available, at most once per 24 hours. +//! Shell integrations launch `vp upgrade --background-check` as an OS-native +//! background process. That command records a retry cooldown before touching +//! the network, then queries the npm registry and caches only whether an update +//! is available. Foreground commands only read this cache. -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + fs::{File, OpenOptions}, + io::{Seek, SeekFrom, Write}, + time::{SystemTime, UNIX_EPOCH}, +}; -use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; use vp_setup::registry; const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; const PROMPT_INTERVAL_SECS: u64 = 24 * 60 * 60; -const CACHE_FILE_NAME: &str = ".upgrade-check.json"; +const CACHE_DIR_NAME: &str = "cache"; +const CACHE_FILE_NAME: &str = "upgrade-check.json"; +const LOCK_FILE_NAME: &str = "upgrade-check.lock"; +const UPGRADE_NOTICE: &str = "A new version of vp is available. Run `vp upgrade` to update."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum UpgradeCheckStatus { + Unknown, + Current, + Available, +} #[expect(clippy::disallowed_types)] // String required for serde JSON round-trip #[derive(Debug, Clone, Serialize, Deserialize)] struct UpgradeCheckCache { - latest: String, + checked_for: String, + status: UpgradeCheckStatus, checked_at: u64, prompted_at: u64, } +impl UpgradeCheckCache { + fn needs_check(&self, current_version: &str, now: u64) -> bool { + self.checked_for != current_version + || now.saturating_sub(self.checked_at) > CHECK_INTERVAL_SECS + } + + fn notice_due(&self, current_version: &str, now: u64) -> bool { + self.checked_for == current_version + && self.status == UpgradeCheckStatus::Available + && now.saturating_sub(self.prompted_at) > PROMPT_INTERVAL_SECS + } +} + +struct UpgradeCheckLock { + _file: File, + cache_dir: vt_path::AbsolutePathBuf, + #[expect(clippy::disallowed_types)] // UUID token is persisted in the lock file + token: String, +} + +impl UpgradeCheckLock { + fn is_current(&self) -> bool { + std::fs::read_to_string(self.cache_dir.join(LOCK_FILE_NAME).as_path()) + .is_ok_and(|token| token == self.token) + } + + fn write_cache(&self, cache: &UpgradeCheckCache) -> std::io::Result<()> { + if !self.is_current() { + return Err(std::io::ErrorKind::NotFound.into()); + } + + persist_cache(&self.cache_dir, cache) + } +} + +fn cache_dir(install_dir: &vt_path::AbsolutePath) -> vt_path::AbsolutePathBuf { + install_dir.join(CACHE_DIR_NAME) +} + +fn cache_path(install_dir: &vt_path::AbsolutePath) -> vt_path::AbsolutePathBuf { + cache_dir(install_dir).join(CACHE_FILE_NAME) +} + fn read_cache(install_dir: &vt_path::AbsolutePath) -> Option { - let cache_path = install_dir.join(CACHE_FILE_NAME); - let data = std::fs::read_to_string(cache_path.as_path()).ok()?; + let data = std::fs::read_to_string(cache_path(install_dir).as_path()).ok()?; serde_json::from_str(&data).ok() } -fn write_cache(install_dir: &vt_path::AbsolutePath, cache: &UpgradeCheckCache) { - let cache_path = install_dir.join(CACHE_FILE_NAME); - if let Ok(data) = serde_json::to_string(cache) { - let _ = std::fs::write(cache_path.as_path(), &data); +fn persist_cache( + cache_dir: &vt_path::AbsolutePath, + cache: &UpgradeCheckCache, +) -> std::io::Result<()> { + let cache_path = cache_dir.join(CACHE_FILE_NAME); + let data = serde_json::to_vec(cache).map_err(std::io::Error::other)?; + let mut temp = tempfile::NamedTempFile::new_in(cache_dir.as_path())?; + temp.write_all(&data)?; + temp.as_file().sync_all()?; + temp.persist(cache_path.as_path()).map_err(|error| error.error)?; + Ok(()) +} + +fn try_acquire_lock(install_dir: &vt_path::AbsolutePath) -> Option { + let cache_dir = cache_dir(install_dir); + if let Err(error) = std::fs::create_dir(cache_dir.as_path()) + && error.kind() != std::io::ErrorKind::AlreadyExists + { + return None; } + let path = cache_dir.join(LOCK_FILE_NAME); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path.as_path()) + .ok()?; + file.try_lock().ok()?; + + let token = uuid::Uuid::new_v4().to_string(); + file.set_len(0).ok()?; + file.seek(SeekFrom::Start(0)).ok()?; + file.write_all(token.as_bytes()).ok()?; + file.sync_all().ok()?; + Some(UpgradeCheckLock { _file: file, cache_dir, token }) } fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() } -fn should_check(cache: Option<&UpgradeCheckCache>, now: u64) -> bool { - if std::env::var_os("VP_NO_UPDATE_CHECK").is_some() - || std::env::var_os("CI").is_some() +fn checks_disabled() -> bool { + std::env::var_os("VP_NO_UPDATE_CHECK").is_some() + || vp_shared::EnvConfig::get().is_ci || std::env::var_os("VP_CLI_TEST").is_some() - { - return false; - } +} - cache.is_none_or(|c| now.saturating_sub(c.checked_at) > CHECK_INTERVAL_SECS) +fn should_check(cache: Option<&UpgradeCheckCache>, current_version: &str, now: u64) -> bool { + !checks_disabled() && cache.is_none_or(|cache| cache.needs_check(current_version, now)) } -fn should_prompt(cache: Option<&UpgradeCheckCache>, now: u64) -> bool { - cache.is_none_or(|c| now.saturating_sub(c.prompted_at) > PROMPT_INTERVAL_SECS) +fn read_due_notice( + install_dir: &vt_path::AbsolutePath, + current_version: &str, + now: u64, +) -> Option { + read_cache(install_dir).filter(|cache| cache.notice_due(current_version, now)) } -/// Returns `true` if `latest` is strictly newer than `current` per semver. -/// Returns `false` for equal versions, downgrades, or unparsable strings. -fn is_newer_version(current: &str, latest: &str) -> bool { - if latest.is_empty() || current == "0.0.0" { - return false; +fn status_for_versions(current: &str, latest: &str) -> UpgradeCheckStatus { + if current == "0.0.0" { + return UpgradeCheckStatus::Current; } + match (node_semver::Version::parse(current), node_semver::Version::parse(latest)) { - (Ok(current), Ok(latest)) => latest > current, - _ => false, + (Ok(current), Ok(latest)) if latest > current => UpgradeCheckStatus::Available, + (Ok(_), Ok(_)) => UpgradeCheckStatus::Current, + _ => UpgradeCheckStatus::Unknown, } } @@ -71,75 +163,89 @@ async fn resolve_version_string() -> Option { registry::resolve_version_string("latest", None).await.ok() } -pub struct UpgradeCheckResult { - install_dir: vt_path::AbsolutePathBuf, - cache: UpgradeCheckCache, -} - -/// Returns an upgrade check result if a newer version is available and the user -/// hasn't been prompted within the last 24 hours. Returns `None` otherwise. -pub async fn check_for_update() -> Option { - let install_dir = vp_shared::get_vp_home().ok()?; +/// Refresh the cached update status. This function intentionally runs in the +/// current process; shell integrations decide how to run that process in the +/// background. +pub async fn run_background_check() { + let Ok(install_dir) = vp_shared::get_vp_home() else { + return; + }; let current_version = env!("CARGO_PKG_VERSION"); let now = now_secs(); - let mut cache = read_cache(&install_dir); - - if should_check(cache.as_ref(), now) { - let prompted_at = cache.as_ref().map_or(0, |c| c.prompted_at); - match resolve_version_string().await { - Some(latest) => { - let new_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; - write_cache(&install_dir, &new_cache); - cache = Some(new_cache); - } - None => { - // Still update checked_at so we back off for 24h instead of - // retrying on every command when the registry is unreachable. - let latest = cache.as_ref().map(|c| c.latest.clone()).unwrap_or_default(); - let failed_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; - write_cache(&install_dir, &failed_cache); - cache = Some(failed_cache); - } - } + if !should_check(read_cache(&install_dir).as_ref(), current_version, now) { + return; } - let cache = cache?; + let Some(lock) = try_acquire_lock(&install_dir) else { + return; + }; - if !is_newer_version(current_version, &cache.latest) { - return None; + // Another process may have refreshed the cache before this process won the + // lock, so check again while holding it. + let cache = read_cache(&install_dir); + let now = now_secs(); + if !should_check(cache.as_ref(), current_version, now) { + return; + } + + let prompted_at = cache + .as_ref() + .filter(|cache| cache.checked_for == current_version) + .map_or(0, |cache| cache.prompted_at); + let pending = UpgradeCheckCache { + checked_for: current_version.to_owned(), + status: UpgradeCheckStatus::Unknown, + checked_at: now, + prompted_at, + }; + + // Persist the cooldown before the first await. If the shell or OS ends this + // process during the request, subsequent commands still avoid a retry storm. + if lock.write_cache(&pending).is_err() { + return; + } + + let status = resolve_version_string().await.map_or(UpgradeCheckStatus::Unknown, |latest| { + status_for_versions(current_version, &latest) + }); + let completed = UpgradeCheckCache { status, checked_at: now_secs(), ..pending }; + let _ = lock.write_cache(&completed); +} + +/// Print a generic one-line upgrade notice from cache and record the prompt time. +#[expect(clippy::print_stderr, clippy::disallowed_macros)] +pub fn display_cached_upgrade_notice() { + if checks_disabled() { + return; } - if !should_prompt(Some(&cache), now) { - return None; + let Ok(install_dir) = vp_shared::get_vp_home() else { + return; + }; + let current_version = env!("CARGO_PKG_VERSION"); + let now = now_secs(); + if read_due_notice(&install_dir, current_version, now).is_none() { + return; } - Some(UpgradeCheckResult { install_dir, cache }) -} + let Some(lock) = try_acquire_lock(&install_dir) else { + return; + }; + let Some(mut cache) = read_due_notice(&install_dir, current_version, now) else { + return; + }; -/// Print a one-line upgrade notice to stderr and record the prompt time. -#[expect(clippy::print_stderr, clippy::disallowed_macros)] -pub fn display_upgrade_notice(result: &UpgradeCheckResult) { - let current_version = env!("CARGO_PKG_VERSION"); - eprintln!( - "\n{} {} {} {}{} {}", - "vp update available:".bright_black(), - current_version.bright_black(), - "\u{2192}".bright_black(), - result.cache.latest.bright_green().bold(), - ", run".bright_black(), - "vp upgrade".bright_green().bold(), - ); - - let mut cache = result.cache.clone(); - cache.prompted_at = now_secs(); - write_cache(&result.install_dir, &cache); + eprintln!("\n{UPGRADE_NOTICE}"); + + cache.prompted_at = now; + let _ = lock.write_cache(&cache); } -/// Whether the upgrade check should run for the given command args. +/// Whether a foreground command may display a cached upgrade notice. /// Returns `false` for commands excluded by design, quiet modes, and /// machine-readable output flags (--silent, -s, --json, --parseable, --format json). -pub fn should_run_for_command(args: &crate::cli::Args) -> bool { +pub fn should_display_for_command(args: &crate::cli::Args) -> bool { if !cfg!(test) && !vp_shared::is_stderr_terminal() { return false; } diff --git a/rfcs/upgrade-check.md b/rfcs/upgrade-check.md index 41893e6bde..6b8c4f5541 100644 --- a/rfcs/upgrade-check.md +++ b/rfcs/upgrade-check.md @@ -29,6 +29,7 @@ The upgrade-command RFC explicitly listed "auto-update on every command invocati 1. Auto-installing updates (user must explicitly run `vp upgrade`) 2. Checking local `vite-plus` package versions (only the global CLI) 3. Showing notices for pre-release/test channel versions +4. Triggering checks from cmd.exe, which has no shell-function integration ## User Stories @@ -38,7 +39,7 @@ The upgrade-command RFC explicitly listed "auto-update on every command invocati $ vp build ...build output... -vp update available: 0.1.0 → 0.2.0, run `vp upgrade` +A new version of vp is available. Run `vp upgrade` to update. ``` ### Story 2: Already Up to Date (no notice) @@ -82,43 +83,47 @@ The check fails silently. No notice, no error, no retry spam. ### Overview ``` -Command starts +Shell session starts or vp() is called │ - ├──────────────────────────────┐ - │ │ - ▼ ▼ - Run the actual command Spawn background task: - │ 1. Check if cache is fresh (<24h) - │ → Yes: read cached version - │ → No: query npm registry, - │ write result to cache file - │ │ - ▼ ▼ - Command finishes Background task finishes - │ │ - ▼ ▼ - If newer version found, print one-line notice - Show tip (existing behavior) - Exit + ├── launch `vp upgrade --background-check` with the shell's native + │ background mechanism (`&`, `job spawn`, or `Start-Process`) + │ │ + │ ├── fresh cache or lock held → exit silently + │ └── acquire OS file lock → atomically write `unknown` cooldown + │ → query registry + │ → atomically write final status + │ + └── foreground `vp` runs the requested command with no network work + │ + └── after completion, read cache and optionally print notice ``` -The background task runs concurrently with the command. When the command finishes, we check if the background task has a result (with a very short timeout — if it hasn't finished, skip the notice this time). +The Rust check command runs normally in its own process. The shell owns backgrounding and process lifecycle, so the foreground command never waits for the check. If the check is still running when the command finishes, the notice can appear after a later command. ### Cache File -Location: `~/.vite-plus/.upgrade-check.json` +Locations: + +- Cache: `~/.vite-plus/cache/upgrade-check.json` +- Cross-process lock: `~/.vite-plus/cache/upgrade-check.lock` Format (single JSON line for simplicity): ```json -{ "latest": "0.2.0", "checked_at": 1711500000, "prompted_at": 1711500000 } +{ + "checked_for": "0.1.0", + "status": "available", + "checked_at": 1711500000, + "prompted_at": 1711500000 +} ``` -- `latest`: The version string returned by the npm registry for the `latest` dist-tag -- `checked_at`: Unix timestamp (seconds) of when the registry was last queried +- `checked_for`: The installed `vp` version this result applies to +- `status`: `available`, `current`, or `unknown`; the target version is not persisted +- `checked_at`: Unix timestamp (seconds) of when the latest check attempt began or completed - `prompted_at`: Unix timestamp (seconds) of when the user was last shown the notice -The file is small and cheap to read. A direct overwrite is sufficient — if corruption occurs (e.g., process killed mid-write), the worst case is one extra registry query. +Cache writes use a temporary file plus atomic replacement. An OS file lock serializes workers and prompt timestamp updates, releases automatically when a process exits, and stores a generation token so a worker cannot write into an install that was removed or replaced while its request was in flight. The worker writes an `unknown` result with a fresh `checked_at` before its first network await, so cancellation, offline registries, and abrupt shell exit cannot cause a request on every invocation. ### Check Logic (Pseudocode) @@ -134,13 +139,13 @@ This means: the registry is queried at most once per day, and even if an update The upgrade notice is printed to **stderr** (like tips), after the command output and before the tip line: ``` -vp update available: 0.1.0 → 0.2.0, run `vp upgrade` +A new version of vp is available. Run `vp upgrade` to update. ``` Styling: - Single line, no indentation -- Dimmed text with version numbers highlighted (current in dim, new in green bold) and `vp upgrade` highlighted +- Does not reveal either the installed or target version The notice is printed **after** the command output and **before** any tip, so it feels like a natural postscript rather than an interruption. @@ -159,9 +164,11 @@ The notice is **not shown** when: | Stderr is not a TTY | Non-interactive / piped / redirected output | | Already prompted within 24h | Show at most once per day, not on every run | -### Commands That Trigger the Check +### Check Triggers and Foreground Suppression -The background check runs on **all** commands except: +The shell integration launches a worker once when a supported interactive shell starts and before every `vp()` wrapper invocation. Bash/Zsh and Fish use background jobs plus `disown`, Nushell uses `job spawn`, and PowerShell uses `Start-Process`. The shell only gates on interactivity; the hidden command owns opt-out, CI, cache, locking, and fetch policy. Redirected worker output is discarded. The worker's cache and lock checks make redundant processes exit without network access. + +The cached notice is not displayed after: - `vp upgrade` (already handles version checking) - `vp implode` (removing the tool) @@ -170,45 +177,34 @@ The background check runs on **all** commands except: - Any command with quiet/machine-readable flags (`--silent`, `-s`, `--json`, `--parseable`, `--format json/list`) - Shim invocations (`node`, `npm`, `npx` via vp) -This keeps the check broadly useful without interfering with special commands. +Shim invocations do not pass through the shell wrapper or foreground notice path. ### File Structure ``` crates/vp_global_cli/src/ ├── upgrade_check.rs # New: cache read/write, background check, display -├── main.rs # Modified: spawn check, display result after command +├── main.rs # Modified: display cached result after command +├── cli.rs # Modified: hidden background-check option +└── commands/env/setup.rs # Modified: shell-native background launchers ``` No new crate — this is a small, focused module in the existing `vp_global_cli` crate. It imports `resolve_version` from the existing `commands/upgrade/registry.rs`. ### Implementation Details -#### Async Background Check +#### Background Check Command ```rust -// In main.rs, before running the command: -let update_handle = if should_run_for_command(&args, &raw_args) { - Some(tokio::spawn(check_for_update())) -} else { - None -}; - -// After command completes: -if let Some(handle) = update_handle { - // Wait up to 500ms for the result — if the network is slow, skip it - match tokio::time::timeout(Duration::from_millis(500), handle).await { - Ok(Ok(Some(result))) => { - display_upgrade_notice(&result); // also records prompted_at - } - _ => {} // Timeout, error, or no update — silent - } +if options.background_check { + run_background_check().await; + return Ok(ExitStatus::default()); } ``` -The 500ms timeout ensures that even if the registry is slow, the user's command exits promptly. In practice, most checks will read from cache (instant) or complete the network request during the time the actual command runs. +`--background-check` is hidden because it is an implementation detail of the generated shell integrations. It deliberately does not detach itself or manage an in-process worker. This keeps OS-specific lifecycle behavior in the shells' native process mechanisms. -`display_upgrade_notice` updates `prompted_at` in the cache file after showing the notice, so subsequent runs within 24h are silent. +Foreground commands call `display_cached_upgrade_notice` after completing. This path performs no network work and only acquires the lock when an available, unprompted cached result exists. ## Design Decisions @@ -223,16 +219,17 @@ The 500ms timeout ensures that even if the registry is slow, the user's command **Rationale**: Deterministic behavior, no surprises. The cache file is tiny and cheap to read. 24 hours is long enough to not annoy, short enough to be useful. -### 2. Background Async (Not Post-Command Blocking) +### 2. Shell-Native Background Process (Not an In-Process Task) -**Decision**: Spawn the registry query concurrently with the command. +**Decision**: Let each supported shell launch the hidden Rust check command in the background. **Alternatives considered**: - Check after the command finishes — adds visible latency +- Spawn a Tokio task inside the foreground CLI — its runtime must wait or cancel the request when the CLI exits - Separate background daemon — heavyweight, harder to manage -**Rationale**: The registry query runs in parallel with the actual command. By the time the command finishes, the check is usually done. The 500ms timeout is a safety net for slow networks. +**Rationale**: The foreground process has no relationship to the registry request and therefore no timeout tail. Native shell jobs provide the expected platform lifecycle behavior without maintaining a daemon or custom detachment layer in Rust. ### 3. Stderr for the Notice @@ -261,7 +258,8 @@ The 500ms timeout ensures that even if the registry is slow, the user's command ### Unit Tests -- Cache read/write: valid JSON, corrupt file, missing file +- Cache read/write: valid JSON, atomic replacement, corrupt/missing files +- OS file-lock exclusivity, automatic release, and install-generation invalidation - `should_check`: respects env vars, cache freshness, TTY detection - Version comparison: same version, different version, pre-release @@ -270,18 +268,19 @@ The 500ms timeout ensures that even if the registry is slow, the user's command - Mock registry server returning a version, verify notice is displayed - Verify no notice when cache is fresh - Verify no notice in CI mode -- Verify timeout behavior (slow mock server) +- Start concurrent checks against a slow mock registry; verify exactly one request and that the cooldown is persisted before the response +- Verify generated shell integrations launch at startup and before each wrapper call ### Manual Testing ```bash # Clear cache to force a fresh check -rm ~/.vite-plus/.upgrade-check.json +rm ~/.vite-plus/cache/upgrade-check.json -# Run any command — should show notice if behind latest -vp --version +# Start a new shell or run any wrapped command to launch the check +vp build -# Run again immediately — should not re-query (cached) +# Run again after the background request completes — should not re-query (cached) vp build # Disable and verify From cbb133e48625ea4e667b0ea78c3ec3d7be0d68e5 Mon Sep 17 00:00:00 2001 From: Liang Date: Sat, 8 Aug 2026 22:13:15 +0800 Subject: [PATCH 2/6] test(cli): cover background upgrade checks --- .../vp_global_cli/src/commands/env/setup.rs | 125 +++++++++ crates/vp_global_cli/src/upgrade_check.rs | 249 +++++++++++++++--- 2 files changed, 335 insertions(+), 39 deletions(-) diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index d8bb6bd4d3..710fc92d2a 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1250,6 +1250,131 @@ mod tests { ); } + #[tokio::test] + async fn test_create_env_files_launch_background_upgrade_checks() { + let temp_dir = TempDir::new().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let _guard = home_guard(temp_dir.path()); + + create_env_files(&home).await.unwrap(); + + let posix = tokio::fs::read_to_string(home.join("env")).await.unwrap(); + let fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + let nu = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); + let powershell = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); + + #[cfg(unix)] + assert!( + std::process::Command::new("sh") + .arg("-n") + .arg(home.join("env").as_path()) + .status() + .unwrap() + .success(), + "POSIX integration should parse as a shell script" + ); + + for (shell, content) in + [("POSIX", posix.as_str()), ("Fish", fish.as_str()), ("Nushell", nu.as_str())] + { + assert!( + content.contains("upgrade --background-check"), + "{shell} integration should invoke the hidden check command" + ); + assert!( + content.matches("__vp_background_upgrade_check").count() >= 3, + "{shell} integration should check at startup and before each vp command" + ); + } + + assert!( + posix.contains("(\n command vp upgrade --background-check") + && posix.contains("&\n disown"), + "POSIX should detach in a subshell without replacing the caller's last background PID" + ); + assert!(fish.contains("&\n disown"), "Fish should detach with shell job control"); + assert!(nu.contains("job spawn"), "Nushell should use its native job API"); + assert!(posix.contains("case $- in *i*)"), "POSIX should require an interactive shell"); + assert!(fish.contains("status is-interactive"), "Fish should require an interactive shell"); + assert!( + nu.contains("if $nu.is-interactive"), + "Nushell should require an interactive shell" + ); + for expected in [ + "--background-check", + "Start-Process", + "-NoNewWindow", + "[Environment]::UserInteractive", + "[Console]::IsInputRedirected", + "[Environment]::GetCommandLineArgs()", + "Command(WithArgs)?", + "e(c)?", + "\\.ps1$", + ] { + assert!( + powershell.contains(expected), + "PowerShell integration should contain `{expected}`" + ); + } + assert!( + powershell.matches("__vp_background_upgrade_check").count() >= 3, + "PowerShell integration should check at startup and before each vp command" + ); + for (shell, content) in [ + ("POSIX", posix.as_str()), + ("Fish", fish.as_str()), + ("Nushell", nu.as_str()), + ("PowerShell", powershell.as_str()), + ] { + assert!( + !content.contains("VP_NO_UPDATE_CHECK") && !content.contains("CI"), + "{shell} should leave update-check policy to the hidden command" + ); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn test_posix_background_upgrade_check_preserves_last_background_pid() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = TempDir::new().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let _guard = home_guard(temp_dir.path()); + + create_env_files(&home).await.unwrap(); + + let vp_path = home.join("bin/vp"); + tokio::fs::create_dir_all(vp_path.parent().unwrap()).await.unwrap(); + tokio::fs::write(&vp_path, "#!/bin/sh\nexit 0\n").await.unwrap(); + std::fs::set_permissions(&vp_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let status = std::process::Command::new("bash") + .env("HOME", temp_dir.path()) + .args([ + "--noprofile", + "--norc", + "-i", + "-c", + r#" +source "$1" +sleep 30 & +server_pid=$! +vp build +checker_pid=$! +kill "$server_pid" +wait "$server_pid" 2>/dev/null +test "$checker_pid" = "$server_pid" +"#, + "bash", + ]) + .arg(home.join("env").as_path()) + .status() + .unwrap(); + + assert!(status.success(), "POSIX wrapper should preserve the caller's `$!`"); + } + #[tokio::test] #[cfg(windows)] #[serial_test::serial] diff --git a/crates/vp_global_cli/src/upgrade_check.rs b/crates/vp_global_cli/src/upgrade_check.rs index 2cff31b644..b5d4457b15 100644 --- a/crates/vp_global_cli/src/upgrade_check.rs +++ b/crates/vp_global_cli/src/upgrade_check.rs @@ -86,6 +86,16 @@ fn read_cache(install_dir: &vt_path::AbsolutePath) -> Option serde_json::from_str(&data).ok() } +#[cfg(test)] +fn write_cache( + install_dir: &vt_path::AbsolutePath, + cache: &UpgradeCheckCache, +) -> std::io::Result<()> { + let cache_dir = cache_dir(install_dir); + std::fs::create_dir_all(cache_dir.as_path())?; + persist_cache(&cache_dir, cache) +} + fn persist_cache( cache_dir: &vt_path::AbsolutePath, cache: &UpgradeCheckCache, @@ -146,6 +156,13 @@ fn read_due_notice( read_cache(install_dir).filter(|cache| cache.notice_due(current_version, now)) } +/// Returns `true` if `latest` is strictly newer than `current` per semver. +/// Returns `false` for equal versions, downgrades, or unparsable strings. +#[cfg(test)] +fn is_newer_version(current: &str, latest: &str) -> bool { + status_for_versions(current, latest) == UpgradeCheckStatus::Available +} + fn status_for_versions(current: &str, latest: &str) -> UpgradeCheckStatus { if current == "0.0.0" { return UpgradeCheckStatus::Current; @@ -268,7 +285,16 @@ pub fn should_display_for_command(args: &crate::cli::Args) -> bool { #[cfg(test)] mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, + }; + use serial_test::serial; + use tokio::net::TcpListener; use super::*; @@ -277,16 +303,83 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - let cache = - UpgradeCheckCache { latest: "1.2.3".to_owned(), checked_at: 1000, prompted_at: 900 }; - write_cache(&dir_path, &cache); + let cache = UpgradeCheckCache { + checked_for: "1.2.3".to_owned(), + status: UpgradeCheckStatus::Available, + checked_at: 1000, + prompted_at: 900, + }; + write_cache(&dir_path, &cache).unwrap(); let loaded = read_cache(&dir_path).expect("should read back cache"); - assert_eq!(loaded.latest, "1.2.3"); + let expected_path = dir_path.join("cache").join("upgrade-check.json"); + assert_eq!(cache_path(&dir_path), expected_path); + assert!(expected_path.as_path().exists()); + assert_eq!(loaded.checked_for, "1.2.3"); + assert_eq!(loaded.status, UpgradeCheckStatus::Available); assert_eq!(loaded.checked_at, 1000); assert_eq!(loaded.prompted_at, 900); } + #[test] + fn cache_write_atomically_replaces_existing_state() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + let mut cache = UpgradeCheckCache { + checked_for: "1.2.3".to_owned(), + status: UpgradeCheckStatus::Unknown, + checked_at: 1000, + prompted_at: 0, + }; + write_cache(&dir_path, &cache).unwrap(); + + cache.status = UpgradeCheckStatus::Available; + cache.checked_at = 2000; + write_cache(&dir_path, &cache).unwrap(); + + let loaded = read_cache(&dir_path).unwrap(); + assert_eq!(loaded.status, UpgradeCheckStatus::Available); + assert_eq!(loaded.checked_at, 2000); + } + + #[test] + fn lock_is_exclusive_and_released_by_its_owner() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + + let lock = try_acquire_lock(&dir_path).expect("first process should acquire the lock"); + let first_token = lock.token.clone(); + assert!(try_acquire_lock(&dir_path).is_none(), "second process must not acquire the lock"); + drop(lock); + + let next = try_acquire_lock(&dir_path).expect("lock should be reusable after owner exits"); + assert_ne!(next.token, first_token, "each owner should write a new generation token"); + } + + #[test] + fn locked_cache_write_does_not_recreate_a_moved_install() { + let dir = tempfile::tempdir().unwrap(); + let install_path = dir.path().join("vite-plus"); + std::fs::create_dir(&install_path).unwrap(); + let install_dir = vt_path::AbsolutePathBuf::new(install_path.clone()).unwrap(); + let lock = try_acquire_lock(&install_dir).expect("worker should acquire the lock"); + let moved_path = dir.path().join("vite-plus.removing"); + std::fs::rename(&install_path, &moved_path).unwrap(); + let cache = UpgradeCheckCache { + checked_for: "1.2.3".to_owned(), + status: UpgradeCheckStatus::Available, + checked_at: 1000, + prompted_at: 0, + }; + + assert!(!lock.is_current(), "moving the install should invalidate the worker"); + assert!( + lock.write_cache(&cache).is_err(), + "an invalidated worker must not write its result" + ); + assert!(!install_path.exists(), "the removed install path must stay absent"); + } + #[test] fn read_cache_returns_none_for_missing_file() { let dir = tempfile::tempdir().unwrap(); @@ -298,7 +391,8 @@ mod tests { fn read_cache_returns_none_for_corrupt_file() { let dir = tempfile::tempdir().unwrap(); let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - std::fs::write(dir_path.join(CACHE_FILE_NAME).as_path(), "not json").unwrap(); + std::fs::create_dir_all(cache_dir(&dir_path).as_path()).unwrap(); + std::fs::write(cache_path(&dir_path).as_path(), "not json").unwrap(); assert!(read_cache(&dir_path).is_none()); } @@ -317,12 +411,18 @@ mod tests { unsafe { if let Some(v) = ci { std::env::set_var("CI", v); + } else { + std::env::remove_var("CI"); } if let Some(v) = test { std::env::set_var("VP_CLI_TEST", v); + } else { + std::env::remove_var("VP_CLI_TEST"); } if let Some(v) = no_check { std::env::set_var("VP_NO_UPDATE_CHECK", v); + } else { + std::env::remove_var("VP_NO_UPDATE_CHECK"); } } } @@ -331,7 +431,7 @@ mod tests { #[serial] fn should_check_returns_true_when_no_cache() { with_env_vars_cleared(|| { - assert!(should_check(None, now_secs())); + assert!(should_check(None, "1.0.0", now_secs())); }); } @@ -340,9 +440,13 @@ mod tests { fn should_check_returns_false_when_cache_fresh() { with_env_vars_cleared(|| { let now = now_secs(); - let cache = - UpgradeCheckCache { latest: "1.0.0".to_owned(), checked_at: now, prompted_at: 0 }; - assert!(!should_check(Some(&cache), now)); + let cache = UpgradeCheckCache { + checked_for: "1.0.0".to_owned(), + status: UpgradeCheckStatus::Current, + checked_at: now, + prompted_at: 0, + }; + assert!(!should_check(Some(&cache), "1.0.0", now)); }); } @@ -353,11 +457,27 @@ mod tests { let now = now_secs(); let stale_time = now - CHECK_INTERVAL_SECS - 1; let cache = UpgradeCheckCache { - latest: "1.0.0".to_owned(), + checked_for: "1.0.0".to_owned(), + status: UpgradeCheckStatus::Current, checked_at: stale_time, prompted_at: 0, }; - assert!(should_check(Some(&cache), now)); + assert!(should_check(Some(&cache), "1.0.0", now)); + }); + } + + #[test] + #[serial] + fn should_check_returns_true_for_a_different_cli_version() { + with_env_vars_cleared(|| { + let now = now_secs(); + let cache = UpgradeCheckCache { + checked_for: "1.0.0".to_owned(), + status: UpgradeCheckStatus::Current, + checked_at: now, + prompted_at: 0, + }; + assert!(should_check(Some(&cache), "1.0.1", now)); }); } @@ -368,40 +488,44 @@ mod tests { unsafe { std::env::set_var("VP_NO_UPDATE_CHECK", "1"); } - assert!(!should_check(None, now_secs())); + assert!(!should_check(None, "1.0.0", now_secs())); }); } #[test] - fn should_prompt_returns_true_when_no_cache() { - assert!(should_prompt(None, now_secs())); - } - - #[test] - fn should_prompt_returns_true_when_never_prompted() { + fn notice_is_due_when_never_prompted() { let cache = UpgradeCheckCache { - latest: "2.0.0".to_owned(), + checked_for: "1.0.0".to_owned(), + status: UpgradeCheckStatus::Available, checked_at: now_secs(), prompted_at: 0, }; - assert!(should_prompt(Some(&cache), now_secs())); + assert!(cache.notice_due("1.0.0", now_secs())); } #[test] - fn should_prompt_returns_false_when_recently_prompted() { + fn notice_is_not_due_when_recently_prompted() { let now = now_secs(); - let cache = - UpgradeCheckCache { latest: "2.0.0".to_owned(), checked_at: now, prompted_at: now }; - assert!(!should_prompt(Some(&cache), now)); + let cache = UpgradeCheckCache { + checked_for: "1.0.0".to_owned(), + status: UpgradeCheckStatus::Available, + checked_at: now, + prompted_at: now, + }; + assert!(!cache.notice_due("1.0.0", now)); } #[test] - fn should_prompt_returns_true_when_prompt_stale() { + fn notice_is_due_when_prompt_stale() { let now = now_secs(); let stale = now - PROMPT_INTERVAL_SECS - 1; - let cache = - UpgradeCheckCache { latest: "2.0.0".to_owned(), checked_at: now, prompted_at: stale }; - assert!(should_prompt(Some(&cache), now)); + let cache = UpgradeCheckCache { + checked_for: "1.0.0".to_owned(), + status: UpgradeCheckStatus::Available, + checked_at: now, + prompted_at: stale, + }; + assert!(cache.notice_due("1.0.0", now)); } #[test] @@ -462,56 +586,103 @@ mod tests { #[test] fn should_run_for_normal_command() { - assert!(should_run_for_command(&parse_args(&["build"]))); + assert!(should_display_for_command(&parse_args(&["build"]))); } #[test] fn should_not_run_for_upgrade() { - assert!(!should_run_for_command(&parse_args(&["upgrade"]))); + assert!(!should_display_for_command(&parse_args(&["upgrade"]))); } #[test] fn should_not_run_for_install_silent() { - assert!(!should_run_for_command(&parse_args(&["install", "--silent"]))); + assert!(!should_display_for_command(&parse_args(&["install", "--silent"]))); } #[test] fn should_not_run_for_dlx_short_silent() { - assert!(!should_run_for_command(&parse_args(&["dlx", "-s", "pkg"]))); + assert!(!should_display_for_command(&parse_args(&["dlx", "-s", "pkg"]))); } #[test] fn should_not_run_for_why_json() { - assert!(!should_run_for_command(&parse_args(&["why", "lodash", "--json"]))); + assert!(!should_display_for_command(&parse_args(&["why", "lodash", "--json"]))); } #[test] fn should_not_run_for_why_parseable() { - assert!(!should_run_for_command(&parse_args(&["why", "lodash", "--parseable"]))); + assert!(!should_display_for_command(&parse_args(&["why", "lodash", "--parseable"]))); } #[test] fn should_not_run_for_outdated_format_json() { - assert!(!should_run_for_command(&parse_args(&["outdated", "--format", "json"]))); + assert!(!should_display_for_command(&parse_args(&["outdated", "--format", "json"]))); } #[test] fn should_not_run_for_pm_list_parseable() { - assert!(!should_run_for_command(&parse_args(&["pm", "list", "--parseable"]))); + assert!(!should_display_for_command(&parse_args(&["pm", "list", "--parseable"]))); } #[test] fn should_not_run_for_pm_list_json() { - assert!(!should_run_for_command(&parse_args(&["pm", "list", "--json"]))); + assert!(!should_display_for_command(&parse_args(&["pm", "list", "--json"]))); } #[test] fn should_not_run_for_env_current_json() { - assert!(!should_run_for_command(&parse_args(&["env", "current", "--json"]))); + assert!(!should_display_for_command(&parse_args(&["env", "current", "--json"]))); } #[test] fn should_run_for_outdated_without_format() { - assert!(should_run_for_command(&parse_args(&["outdated"]))); + assert!(should_display_for_command(&parse_args(&["outdated"]))); + } + + #[tokio::test(flavor = "current_thread")] + #[serial] + async fn concurrent_slow_checks_start_one_request_and_back_off_before_it_finishes() { + let home = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let registry = format!("http://{}", listener.local_addr().unwrap()); + let request_count = Arc::new(AtomicUsize::new(0)); + let server_request_count = Arc::clone(&request_count); + let server = tokio::spawn(async move { + loop { + let (connection, _) = listener.accept().await.unwrap(); + server_request_count.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + let _connection = connection; + std::future::pending::<()>().await; + }); + } + }); + + let _env = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { + vite_plus_home: Some(home.path().to_path_buf()), + npm_registry: registry, + ..vp_shared::EnvConfig::for_test() + }); + + let checks = (0..5).map(|_| tokio::spawn(run_background_check())).collect::>(); + + tokio::time::timeout(Duration::from_secs(2), async { + while request_count.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("at least one registry request should start"); + tokio::time::sleep(Duration::from_millis(200)).await; + + let observed_requests = request_count.load(Ordering::SeqCst); + let cache_exists = home.path().join(CACHE_DIR_NAME).join(CACHE_FILE_NAME).exists(); + for check in checks { + check.abort(); + } + server.abort(); + + assert_eq!(observed_requests, 1, "concurrent checks must share one registry request"); + assert!(cache_exists, "the retry cooldown must be persisted before awaiting the registry"); } } From 955b6f6cdd91fed5f63bbe381384097c1b0367cb Mon Sep 17 00:00:00 2001 From: Liang Date: Sat, 8 Aug 2026 22:13:37 +0800 Subject: [PATCH 3/6] test(cli): snapshot background upgrade notice --- .../command_upgrade_check/mock-manifest.json | 9 ++++ .../command_upgrade_check/snapshots.toml | 14 +++++++ .../command_upgrade_background_notice.md | 41 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/mock-manifest.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/mock-manifest.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/mock-manifest.json new file mode 100644 index 0000000000..aec06b40b2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/mock-manifest.json @@ -0,0 +1,9 @@ +{ + "vite-plus/latest": { + "version": "999.0.0", + "dist": { + "tarball": "unused", + "integrity": "sha512-unused" + } + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots.toml index d36e269d3a..d8325540c5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots.toml @@ -5,3 +5,17 @@ skip-platforms = ["windows", { os = "linux", libc = "musl" }] steps = [ { argv = ["vp", "upgrade", "--check", "--tag", "alpha"], comment = "alpha tag avoids release-day flake (dev version equals npm latest right after a release, hiding the Update-available branch)", continue-on-failure = true }, ] + +[[case]] +name = "command_upgrade_background_notice" +vp = "global" +local-registry = true +unset-env = ["VP_CLI_TEST"] +comment = "A background check records an available update without contaminating machine output, then the foreground CLI shows the generic notice at most once per prompt interval." +steps = [ + { argv = ["vp", "upgrade", "--background-check"], snapshot = false }, + { argv = ["vpt", "grep-file", "$VP_HOME/cache/upgrade-check.json", '"status":"available"'], snapshot = false }, + { argv = ["vp", "env", "list", "--json"], comment = "Machine-readable output does not consume the pending notice.", snapshot = false }, + { argv = ["vp", "env", "off"], comment = "The next interactive command displays the cached update notice." }, + { argv = ["vp", "env", "off"], comment = "A subsequent command stays quiet after the notice timestamp is recorded." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md new file mode 100644 index 0000000000..bad7ecc8e5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md @@ -0,0 +1,41 @@ +# command_upgrade_background_notice + +A background check records an available update without contaminating machine output, then the foreground CLI shows the generic notice at most once per prompt interval. + +## `vp upgrade --background-check` + + +## `vpt grep-file $VP_HOME/cache/upgrade-check.json '"status":"available"'` + + +## `vp env list --json` + +Machine-readable output does not consume the pending notice. + + +## `vp env off` + +The next interactive command displays the cached update notice. + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Node.js management set to system-first. + +All vp commands and shims will now prefer system Node.js, falling back to managed if not found. + +Run `vp env on` to always use Vite+ managed Node.js. + +A new version of vp is available. Run `vp upgrade` to update. +``` + +## `vp env off` + +A subsequent command stays quiet after the notice timestamp is recorded. + +``` +VITE+ - The Unified Toolchain for the Web + +Node.js management is already set to system-first. +All vp commands and shims will prefer system Node.js, falling back to managed if not found. +``` From 4df4e2dd1c2013fd379ff03594ca65f7aa94f71a Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 09:22:03 +0800 Subject: [PATCH 4/6] fix(cli): validate upgrade lock by file identity --- Cargo.lock | 1 + Cargo.toml | 1 + crates/vp_global_cli/Cargo.toml | 1 + crates/vp_global_cli/src/upgrade_check.rs | 24 ++++++++--------------- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1801b20276..ca8adbd0ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8445,6 +8445,7 @@ dependencies = [ "node-semver", "owo-colors", "oxc_resolver", + "same-file", "serde", "serde_json", "serial_test", diff --git a/Cargo.toml b/Cargo.toml index 1ed96ac02c..0b91db7ac4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -251,6 +251,7 @@ rolldown-notify = "10.2.0" rolldown-notify-debouncer-full = "0.7.5" rustc-hash = "2.1.1" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +same-file = "1.0.6" schemars = "1.0.0" self_cell = "1.2.0" node-semver = "2.2.0" diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index 067784e53b..4c879e80f5 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -28,6 +28,7 @@ tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } owo-colors = { workspace = true } +same-file = { workspace = true } oxc_resolver = { workspace = true } crossterm = { workspace = true } indexmap = { workspace = true } diff --git a/crates/vp_global_cli/src/upgrade_check.rs b/crates/vp_global_cli/src/upgrade_check.rs index b5d4457b15..8f1f27f1cb 100644 --- a/crates/vp_global_cli/src/upgrade_check.rs +++ b/crates/vp_global_cli/src/upgrade_check.rs @@ -7,7 +7,7 @@ use std::{ fs::{File, OpenOptions}, - io::{Seek, SeekFrom, Write}, + io::Write, time::{SystemTime, UNIX_EPOCH}, }; @@ -54,14 +54,13 @@ impl UpgradeCheckCache { struct UpgradeCheckLock { _file: File, cache_dir: vt_path::AbsolutePathBuf, - #[expect(clippy::disallowed_types)] // UUID token is persisted in the lock file - token: String, + identity: same_file::Handle, } impl UpgradeCheckLock { fn is_current(&self) -> bool { - std::fs::read_to_string(self.cache_dir.join(LOCK_FILE_NAME).as_path()) - .is_ok_and(|token| token == self.token) + same_file::Handle::from_path(self.cache_dir.join(LOCK_FILE_NAME).as_path()) + .is_ok_and(|identity| identity == self.identity) } fn write_cache(&self, cache: &UpgradeCheckCache) -> std::io::Result<()> { @@ -117,21 +116,16 @@ fn try_acquire_lock(install_dir: &vt_path::AbsolutePath) -> Option u64 { @@ -348,12 +342,10 @@ mod tests { let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); let lock = try_acquire_lock(&dir_path).expect("first process should acquire the lock"); - let first_token = lock.token.clone(); assert!(try_acquire_lock(&dir_path).is_none(), "second process must not acquire the lock"); drop(lock); - let next = try_acquire_lock(&dir_path).expect("lock should be reusable after owner exits"); - assert_ne!(next.token, first_token, "each owner should write a new generation token"); + try_acquire_lock(&dir_path).expect("lock should be reusable after owner exits"); } #[test] From ff9c18335688cfab243afd6f8443ab40b79abfef Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 09:28:48 +0800 Subject: [PATCH 5/6] test(cli): scope moved-install check to unix --- crates/vp_global_cli/src/upgrade_check.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/vp_global_cli/src/upgrade_check.rs b/crates/vp_global_cli/src/upgrade_check.rs index 8f1f27f1cb..4cc1d72e66 100644 --- a/crates/vp_global_cli/src/upgrade_check.rs +++ b/crates/vp_global_cli/src/upgrade_check.rs @@ -348,6 +348,7 @@ mod tests { try_acquire_lock(&dir_path).expect("lock should be reusable after owner exits"); } + #[cfg(unix)] #[test] fn locked_cache_write_does_not_recreate_a_moved_install() { let dir = tempfile::tempdir().unwrap(); From 2b00abaa37403d47b6ea73de59de6563ac568a81 Mon Sep 17 00:00:00 2001 From: Liang Date: Tue, 11 Aug 2026 07:57:30 +0800 Subject: [PATCH 6/6] fix(cli): run upgrade check once per shell --- .../vp_global_cli/src/commands/env/setup.rs | 113 +++++------------- rfcs/upgrade-check.md | 4 +- 2 files changed, 34 insertions(+), 83 deletions(-) diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index d05bb3140d..dce05cdeb9 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -537,21 +537,20 @@ case ":${PATH}:" in esac unset __vp_bin -# Start the Rust update checker as a detached shell job. The checker itself +# Start the Rust update checker once for each interactive shell. The checker # uses a cross-process lock and returns immediately when the cache is fresh. -__vp_background_upgrade_check() { - case $- in *i*) ;; *) return 0 ;; esac - ( - command vp upgrade --background-check /dev/null 2>&1 & - disown 2>/dev/null || true - ) -} -__vp_background_upgrade_check +case $- in + *i*) + ( + command vp upgrade --background-check /dev/null 2>&1 & + disown 2>/dev/null || true + ) + ;; +esac # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. vp() { - __vp_background_upgrade_check if [ "$1" = "env" ] && [ "$2" = "use" ]; then case " $* " in *" -h "*|*" --help "*) command vp "$@"; return; esac __vp_out="$(VP_ENV_USE_EVAL_ENABLE=1 VP_SHELL=sh command vp "$@")" || return $? @@ -584,17 +583,14 @@ set -l __vp_idx (contains -i -- __VP_BIN__ $PATH) and set -e PATH[$__vp_idx] set -gx PATH __VP_BIN__ $PATH -function __vp_background_upgrade_check - status is-interactive; or return +if status is-interactive command vp upgrade --background-check /dev/null 2>&1 & disown 2>/dev/null end -__vp_background_upgrade_check # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. function vp - __vp_background_upgrade_check if test (count $argv) -ge 2; and test "$argv[1]" = "env"; and test "$argv[2]" = "use" if contains -- -h $argv; or contains -- --help $argv command vp $argv; return @@ -626,17 +622,13 @@ const ENV_TEMPLATE_NU: &str = r#"# Vite+ environment setup (https://viteplus.dev $env.VP_HOME = ("__VP_HOME__" | path expand --no-symlink) $env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") -def __vp_background_upgrade_check [] { - if $nu.is-interactive { - job spawn { ^vp upgrade --background-check | complete | ignore } | ignore - } +if $nu.is-interactive { + job spawn { ^vp upgrade --background-check | complete | ignore } | ignore } -__vp_background_upgrade_check # Shell function wrapper: intercepts `vp env use` to parse its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. def --env --wrapped vp [...args: string@"nu-complete vp"] { - __vp_background_upgrade_check if ($args | length) >= 2 and $args.0 == "env" and $args.1 == "use" { if ("-h" in $args) or ("--help" in $args) { ^vp ...$args @@ -698,7 +690,7 @@ if ($env:Path -split ';' -notcontains $__vp_bin) { $env:Path = "$__vp_bin;$env:Path" } -function __vp_background_upgrade_check { +& { if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { return } $__vp_args = [Environment]::GetCommandLineArgs() if ($__vp_args -match '^-(NonInteractive|noni|File|f|Command(WithArgs)?|c(wa)?|EncodedCommand|e(c)?)$' -or $__vp_args -match '\.ps1$') { return } @@ -707,12 +699,10 @@ function __vp_background_upgrade_check { Start-Process -FilePath (Join-Path $__vp_bin "vp") -ArgumentList "upgrade", "--background-check" -NoNewWindow -RedirectStandardError $__vp_null -ErrorAction SilentlyContinue | Out-Null } catch {} } -__vp_background_upgrade_check # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. function vp { - __vp_background_upgrade_check if ($args.Count -ge 2 -and $args[0] -eq "env" -and $args[1] -eq "use") { if ($args -contains "-h" -or $args -contains "--help") { & (Join-Path $__vp_bin "vp") @args; return @@ -1298,7 +1288,7 @@ mod tests { } #[tokio::test] - async fn test_create_env_files_launch_background_upgrade_checks() { + async fn test_create_env_files_launch_one_background_upgrade_check() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let _guard = home_guard(temp_dir.path()); @@ -1321,27 +1311,34 @@ mod tests { "POSIX integration should parse as a shell script" ); - for (shell, content) in - [("POSIX", posix.as_str()), ("Fish", fish.as_str()), ("Nushell", nu.as_str())] - { - assert!( - content.contains("upgrade --background-check"), - "{shell} integration should invoke the hidden check command" + for (shell, content) in [ + ("POSIX", posix.as_str()), + ("Fish", fish.as_str()), + ("Nushell", nu.as_str()), + ("PowerShell", powershell.as_str()), + ] { + assert_eq!( + content.matches("--background-check").count(), + 1, + "{shell} integration should check once when the shell starts" ); assert!( - content.matches("__vp_background_upgrade_check").count() >= 3, - "{shell} integration should check at startup and before each vp command" + !content.contains("__vp_background_upgrade_check"), + "{shell} integration should not check before each vp command" ); } assert!( - posix.contains("(\n command vp upgrade --background-check") - && posix.contains("&\n disown"), + posix.contains("(\n command vp upgrade --background-check") + && posix.contains("&\n disown"), "POSIX should detach in a subshell without replacing the caller's last background PID" ); assert!(fish.contains("&\n disown"), "Fish should detach with shell job control"); assert!(nu.contains("job spawn"), "Nushell should use its native job API"); - assert!(posix.contains("case $- in *i*)"), "POSIX should require an interactive shell"); + assert!( + posix.contains("case $- in") && posix.contains("*i*)"), + "POSIX should require an interactive shell" + ); assert!(fish.contains("status is-interactive"), "Fish should require an interactive shell"); assert!( nu.contains("if $nu.is-interactive"), @@ -1363,10 +1360,6 @@ mod tests { "PowerShell integration should contain `{expected}`" ); } - assert!( - powershell.matches("__vp_background_upgrade_check").count() >= 3, - "PowerShell integration should check at startup and before each vp command" - ); for (shell, content) in [ ("POSIX", posix.as_str()), ("Fish", fish.as_str()), @@ -1380,48 +1373,6 @@ mod tests { } } - #[cfg(unix)] - #[tokio::test] - async fn test_posix_background_upgrade_check_preserves_last_background_pid() { - use std::os::unix::fs::PermissionsExt; - - let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); - - create_env_files(&home).await.unwrap(); - - let vp_path = home.join("bin/vp"); - tokio::fs::create_dir_all(vp_path.parent().unwrap()).await.unwrap(); - tokio::fs::write(&vp_path, "#!/bin/sh\nexit 0\n").await.unwrap(); - std::fs::set_permissions(&vp_path, std::fs::Permissions::from_mode(0o755)).unwrap(); - - let status = std::process::Command::new("bash") - .env("HOME", temp_dir.path()) - .args([ - "--noprofile", - "--norc", - "-i", - "-c", - r#" -source "$1" -sleep 30 & -server_pid=$! -vp build -checker_pid=$! -kill "$server_pid" -wait "$server_pid" 2>/dev/null -test "$checker_pid" = "$server_pid" -"#, - "bash", - ]) - .arg(home.join("env").as_path()) - .status() - .unwrap(); - - assert!(status.success(), "POSIX wrapper should preserve the caller's `$!`"); - } - #[tokio::test] #[cfg(windows)] #[serial_test::serial] diff --git a/rfcs/upgrade-check.md b/rfcs/upgrade-check.md index 6b8c4f5541..db6c945f05 100644 --- a/rfcs/upgrade-check.md +++ b/rfcs/upgrade-check.md @@ -83,7 +83,7 @@ The check fails silently. No notice, no error, no retry spam. ### Overview ``` -Shell session starts or vp() is called +Shell session starts │ ├── launch `vp upgrade --background-check` with the shell's native │ background mechanism (`&`, `job spawn`, or `Start-Process`) @@ -166,7 +166,7 @@ The notice is **not shown** when: ### Check Triggers and Foreground Suppression -The shell integration launches a worker once when a supported interactive shell starts and before every `vp()` wrapper invocation. Bash/Zsh and Fish use background jobs plus `disown`, Nushell uses `job spawn`, and PowerShell uses `Start-Process`. The shell only gates on interactivity; the hidden command owns opt-out, CI, cache, locking, and fetch policy. Redirected worker output is discarded. The worker's cache and lock checks make redundant processes exit without network access. +The shell integration launches one worker when a supported interactive shell starts. Bash/Zsh and Fish use background jobs plus `disown`, Nushell uses `job spawn`, and PowerShell uses `Start-Process`. The shell only gates on interactivity; the hidden command owns opt-out, CI, cache, locking, and fetch policy. Redirected worker output is discarded. The worker's cache and lock checks coordinate checks from concurrently opened shells without adding work to each `vp` invocation. The cached notice is not displayed after: