From 324a449f9383a391173e15a064366fc170570fc4 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Tue, 10 Nov 2026 14:47:45 +0900 Subject: [PATCH 1/6] fix(upgrade): keep existing version directories when an upgrade fails A failed upgrade removed its target version directory even when that directory existed before the upgrade started. Installing a kept version or the rollback target again, then failing during dependency installation, deleted that version. `vp upgrade --rollback` then failed because `.previous-version` pointed to a missing directory. Create the version directory with `create_dir`, record whether this run created it, and remove it on failure only in that case. `vp upgrade` and the legacy `vp-setup` path share the helper. --- .../vp_global_cli/src/commands/upgrade/mod.rs | 6 +-- crates/vp_installer/src/legacy.rs | 5 +- crates/vp_setup/src/install.rs | 49 +++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index bcc9056a14..539365e6f4 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -196,7 +196,7 @@ pub async fn execute(options: UpgradeOptions) -> Result { options.force, ); let version_dir = install_dir.join(&install_dir_name); - tokio::fs::create_dir_all(&version_dir).await?; + let created = install::create_version_dir(&version_dir).await?; // Step 9: Extract platform binary and install via npm let result = install_platform_and_main( @@ -211,10 +211,8 @@ pub async fn execute(options: UpgradeOptions) -> Result { ) .await; - // On failure, clean up the version directory if result.is_err() { - tracing::debug!("Cleaning up failed install at {}", version_dir.as_path().display()); - let _ = tokio::fs::remove_dir_all(&version_dir).await; + install::discard_failed_version_dir(&version_dir, created).await; } result diff --git a/crates/vp_installer/src/legacy.rs b/crates/vp_installer/src/legacy.rs index 215a1b8f98..e858a63755 100644 --- a/crates/vp_installer/src/legacy.rs +++ b/crates/vp_installer/src/legacy.rs @@ -40,7 +40,7 @@ pub(super) async fn install( if !same_version { let install_dir = &dirs.data; let version_dir = install_dir.join(target_version); - tokio::fs::create_dir_all(&version_dir).await?; + let created = install::create_version_dir(&version_dir).await?; let result = install_new_version( opts, @@ -52,9 +52,8 @@ pub(super) async fn install( ) .await; - // On failure, clean up the partial version directory (matches vp upgrade behavior) if result.is_err() { - let _ = tokio::fs::remove_dir_all(&version_dir).await; + install::discard_failed_version_dir(&version_dir, created).await; } result?; diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index f36ed40896..52a7b73f6a 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -641,6 +641,36 @@ fn forced_reinstall_dir_name(version: &str) -> String { format!("{version}+force.{}.{}", process::id(), nanos) } +/// Create `version_dir` and return whether this call created it. +/// +/// The directory can already exist because a kept version or the rollback +/// target is being installed again. Pass the result to +/// [`discard_failed_version_dir`] so a failed install keeps that directory. +pub async fn create_version_dir(version_dir: &AbsolutePath) -> Result { + if let Some(parent) = version_dir.parent() { + tokio::fs::create_dir_all(parent).await?; + } + match tokio::fs::create_dir(version_dir).await { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false), + Err(error) => Err(error.into()), + } +} + +/// Remove a failed install only when [`create_version_dir`] created it. +pub async fn discard_failed_version_dir(version_dir: &AbsolutePath, created: bool) { + if !created { + tracing::debug!("Keeping existing version directory {}", version_dir.as_path().display()); + return; + } + tracing::debug!("Cleaning up failed install at {}", version_dir.as_path().display()); + if let Err(error) = tokio::fs::remove_dir_all(version_dir).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!("Failed to remove {}: {error}", version_dir.as_path().display()); + } +} + /// Clean up old version directories, keeping at most `max_keep` versions. /// /// Sorts by creation time (newest first, matching install.sh behavior) and removes @@ -1171,6 +1201,25 @@ mod tests { assert!(result.is_err(), "cleanup_old_versions should error on non-existent dir"); } + #[tokio::test] + async fn failed_install_removes_only_a_directory_it_created() { + let temp = tempfile::tempdir().unwrap(); + let data = AbsolutePathBuf::new(temp.path().join("data")).unwrap(); + + let fresh = data.join("1.2.0"); + assert!(create_version_dir(&fresh).await.unwrap()); + discard_failed_version_dir(&fresh, true).await; + assert!(!fresh.as_path().exists()); + + // A kept version or rollback target survives a failed reinstall. + let existing = data.join("1.1.0"); + std::fs::create_dir_all(existing.join("bin")).unwrap(); + std::fs::write(existing.join("bin").join("vp"), "kept").unwrap(); + assert!(!create_version_dir(&existing).await.unwrap()); + discard_failed_version_dir(&existing, false).await; + assert_eq!(std::fs::read_to_string(existing.join("bin").join("vp")).unwrap(), "kept"); + } + #[tokio::test] async fn test_write_upgrade_log_creates_log_in_parent_dir() { let temp = tempfile::tempdir().unwrap(); From ac63d1335220a30cea5bc0226704da3ac1dd9059 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Tue, 10 Nov 2026 14:50:09 +0900 Subject: [PATCH 2/6] fix(upgrade): serialize upgrades and rollbacks Concurrent `vp upgrade` runs shared the target version directory, the `current.new` temporary link, `.previous-version`, and old-version cleanup without coordination. Two terminals upgrading to the same version could overwrite the rollback record with the new version, and a run that failed during the link swap could remove the directory the other run had just activated. Add a blocking file lock at `/.install.lock`. `vp upgrade` takes it after downloading and verifying the payload and holds it through cleanup. After the wait it reads `current` again and stops when another run already activated the target version. `vp upgrade --rollback` takes the same lock. The child that `refresh_shims` starts runs setup in place and does not take the lock, so the parent cannot deadlock on it. --- .../vp_global_cli/src/commands/upgrade/mod.rs | 21 ++++++++- crates/vp_setup/src/install.rs | 47 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index 539365e6f4..9da6b504db 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -179,6 +179,25 @@ pub async fn execute(options: UpgradeOptions) -> Result { // Step 7: Verify integrity integrity::verify_integrity(&platform_data, &resolved.platform_integrity)?; + // Hold the lock until cleanup finishes. Another process may have activated + // the target version while this one waited. + let _install_lock = install::lock_install_dir(install_dir).await?; + let active_install_dir = install::read_current_version(install_dir).await; + if !options.force + && active_install_dir + .as_deref() + .is_some_and(|active| install::is_install_dir_for_version(active, &resolved.version)) + { + if !options.silent { + vp_shared::output::print_stdout_line(format_args!( + "\n{} Already up to date ({})", + style(output::CHECK).green(), + resolved.version + )); + } + return Ok(ExitStatus::default()); + } + if !options.silent { output::info("installing..."); } @@ -189,7 +208,6 @@ pub async fn execute(options: UpgradeOptions) -> Result { // version directory on Windows because the running `vp.exe` is locked. // Install into a unique semver build-metadata directory instead, then // repoint `current` after the install has completed. - let active_install_dir = install::read_current_version(install_dir).await; let install_dir_name = install::target_install_dir_name( &resolved.version, active_install_dir.as_deref(), @@ -301,6 +319,7 @@ async fn execute_rollback( install_dir: &AbsolutePathBuf, silent: bool, ) -> Result { + let _install_lock = install::lock_install_dir(install_dir).await?; let previous = install::read_previous_version(install_dir) .await? .ok_or_else(|| Error::Upgrade("No previous version found. Cannot rollback.".into()))?; diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 52a7b73f6a..218ebd3033 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -641,6 +641,32 @@ fn forced_reinstall_dir_name(version: &str) -> String { format!("{version}+force.{}.{}", process::id(), nanos) } +const INSTALL_LOCK_FILE: &str = ".install.lock"; + +/// Serialize installation changes in `install_dir` across processes. +/// +/// Hold the returned file while changing version directories, `current`, +/// `.previous-version`, or old-version cleanup. A child that only refreshes +/// shims runs in place and must not take this lock, or its parent deadlocks. +pub async fn lock_install_dir(install_dir: &AbsolutePath) -> Result { + tokio::fs::create_dir_all(install_dir).await?; + let path = install_dir.join(INSTALL_LOCK_FILE); + tokio::task::spawn_blocking(move || { + // Keep the file in place so every process locks the same inode. + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path.as_path())?; + file.lock()?; + Ok::<_, std::io::Error>(file) + }) + .await + .map_err(|error| Error::Setup(format!("Task join error: {error}").into()))? + .map_err(Into::into) +} + /// Create `version_dir` and return whether this call created it. /// /// The directory can already exist because a kept version or the rollback @@ -1201,6 +1227,27 @@ mod tests { assert!(result.is_err(), "cleanup_old_versions should error on non-existent dir"); } + #[tokio::test] + async fn install_lock_waits_for_its_owner() { + let temp = tempfile::tempdir().unwrap(); + let data = AbsolutePathBuf::new(temp.path().join("data")).unwrap(); + + let first = lock_install_dir(&data).await.unwrap(); + let second = lock_install_dir(&data); + tokio::pin!(second); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(150), &mut second).await.is_err() + ); + drop(first); + let second = + tokio::time::timeout(std::time::Duration::from_secs(5), second).await.unwrap().unwrap(); + + let contender = std::fs::File::open(data.join(INSTALL_LOCK_FILE)).unwrap(); + assert!(matches!(contender.try_lock(), Err(std::fs::TryLockError::WouldBlock))); + drop(second); + assert!(contender.try_lock().is_ok()); + } + #[tokio::test] async fn failed_install_removes_only_a_directory_it_created() { let temp = tempfile::tempdir().unwrap(); From 2643c13e12a08ea171af0c04a3e5095656dfca0b Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Tue, 10 Nov 2026 15:00:27 +0900 Subject: [PATCH 3/6] fix(setup): serialize standalone deployment and legacy installation A standalone install from `install.sh` or `install.ps1` deploys the downloaded binary through self-setup, which changes `current`, `.previous-version`, and old versions like `vp upgrade`. The legacy `vp-setup` path does the same. Neither path took a lock, so either one could race an upgrade or another installation. Take the installation lock in the self-setup deploy path and around the legacy activation. In-place self-setup does not take it because `vp upgrade` starts that child while holding the lock. The unowned-directory implode snapshot now lists `.install.lock` with the other preserved Vite+ files. --- .../command_implode/snapshots/implode_unowned_directory.md | 1 + crates/vp_global_cli/src/self_setup.rs | 5 +++++ crates/vp_installer/src/legacy.rs | 3 +++ 3 files changed, 9 insertions(+) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_implode/snapshots/implode_unowned_directory.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_implode/snapshots/implode_unowned_directory.md index 3b2d533636..2e3dd9b5c7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_implode/snapshots/implode_unowned_directory.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_implode/snapshots/implode_unowned_directory.md @@ -58,6 +58,7 @@ Only the newly owned cache is removed; the unmarked parent is preserved ✓ Vite+ removed its verified files and shell entries from your system. note: Some paths were preserved to protect unverified data. Inspect them before removing anything: + /Documents/.install.lock /Documents/0.0.0-test /Documents/bin /Documents/config.json diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index db330f9017..112f3d9d3d 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -254,6 +254,11 @@ async fn run( return Err(Error::Other("Invalid local installation version".into())); } + // A deployment changes `current`, `.previous-version`, and old versions like + // `vp upgrade`. In-place setup is the child of an upgrade that holds the lock. + let _install_lock = + if deploy { Some(install::lock_install_dir(&dirs.data).await?) } else { None }; + // 1. Prepare the payload before activating it. Upgrade has already done this in the in-place case. let previous_version = install::read_current_version(&dirs.data).await; let version_dir = if let Some(package) = user_package { diff --git a/crates/vp_installer/src/legacy.rs b/crates/vp_installer/src/legacy.rs index e858a63755..875b723724 100644 --- a/crates/vp_installer/src/legacy.rs +++ b/crates/vp_installer/src/legacy.rs @@ -14,6 +14,8 @@ pub(super) async fn install( target_version: &str, platform_data: &[u8], ) -> Result<(), Box> { + // Serialize activation with `vp upgrade` and standalone deployment. + let install_lock = install::lock_install_dir(&dirs.data).await?; let current_version = install::read_current_version(&dirs.data).await; // Same version only if the binary is intact — a corrupted install needs a full reinstall. @@ -58,6 +60,7 @@ pub(super) async fn install( result?; } + drop(install_lock); // --- Post-activation setup (always runs, even for same-version repair) --- // All steps below are best-effort: the core install succeeded once `current` From 36f7a8e2c831c9e04e4f74b4d2978b5b31974870 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Tue, 10 Nov 2026 15:34:05 +0900 Subject: [PATCH 4/6] test(upgrade): cover rollback and failed reinstall Add two global snapshot cases next to the existing rollback case. `command_upgrade_rollback_deployed` deploys two versions, removes the setup marker from the rollback target, and rolls back. The rollback holds the installation lock while the shim refresh runs in-place setup for that target, so a lock in the in-place path makes the step time out. It then checks `.previous-version` and the recreated marker. `command_upgrade_failed_reinstall_keeps_existing_version` makes extraction fail for an existing `0.3.1` directory and checks that the directory survives. Like `command_upgrade_parent_workspace`, it downloads the released 0.3.1 payload, because the upgrade verifies a published package before it touches the version directory. --- .../command_upgrade_rollback/snapshots.toml | 34 +++++++++++++ ...failed_reinstall_keeps_existing_version.md | 27 ++++++++++ .../command_upgrade_rollback_deployed.md | 50 +++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_failed_reinstall_keeps_existing_version.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_rollback_deployed.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml index a5efec52e6..6be0aaa7c9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml @@ -5,3 +5,37 @@ skip-platforms = ["windows"] steps = [ { argv = ["vp", "upgrade", "--rollback"], comment = "should fail with no previous version", continue-on-failure = true }, ] + +[[case]] +name = "command_upgrade_rollback_deployed" +vp = "global" +skip-platforms = ["windows"] +comment = "A rollback holds the installation lock while an unmarked target completes in-place setup, so the child must not take that lock." +env = { VP_SKIP_DEPS_INSTALL = "1", VP_NODE_MANAGER = "no", VP_SELF_SETUP_NO_MODIFY_PATH = "1" } +steps = [ + { argv = ["vpt", "mkdir", "external"], snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/vp"], snapshot = false }, + { argv = ["./external/vp"], tty = false, envs = [["VP_HOME", "${workspace}/home"], ["VP_VERSION", "rollback-a"]], comment = "Deploy the first version", snapshot = false }, + { argv = ["./external/vp"], tty = false, envs = [["VP_HOME", "${workspace}/home"], ["VP_VERSION", "rollback-b"]], comment = "Deploy the second version, which records the first as the rollback target", snapshot = false }, + ["vpt", "print-file", "home/.previous-version"], + { argv = ["vpt", "rm", "home/rollback-a/bin/.vp-setup-complete"], comment = "The rollback target must run in-place setup during the shim refresh", snapshot = false }, + # The progress line prints the running CLI version, which changes on every release. + { argv = ["vp", "upgrade", "--rollback"], envs = [["VP_HOME", "${workspace}/home"]], comment = "A deadlock fails this step by timeout", timeout = 60000, snapshot = false }, + ["vpt", "print-file", "home/.previous-version"], + ["vpt", "stat-file", "home/rollback-a/bin/.vp-setup-complete", "--assert", "file"], +] + +[[case]] +name = "command_upgrade_failed_reinstall_keeps_existing_version" +vp = "global" +skip-platforms = ["windows"] +comment = "A failed upgrade into an existing version directory keeps that directory, which can be the rollback target." +steps = [ + { argv = ["vpt", "write-file", "$VP_HOME/0.3.1/keep.txt", "rollback target\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/0.3.1/bin", "not a directory\n"], comment = "Make extraction fail after the download is verified", snapshot = false }, + # Pin the released payload so the test also runs when the checkout version is unpublished. + # `--silent` keeps the platform suffix and running CLI version out of the snapshot. + { argv = ["vp", "upgrade", "0.3.1", "--silent"], timeout = 120000, continue-on-failure = true }, + ["vpt", "print-file", "$VP_HOME/0.3.1/keep.txt"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_failed_reinstall_keeps_existing_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_failed_reinstall_keeps_existing_version.md new file mode 100644 index 0000000000..35a6489db4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_failed_reinstall_keeps_existing_version.md @@ -0,0 +1,27 @@ +# command_upgrade_failed_reinstall_keeps_existing_version + +A failed upgrade into an existing version directory keeps that directory, which can be the rollback target. + +## `vpt write-file $VP_HOME/0.3.1/keep.txt 'rollback target +'` + + +## `vpt write-file $VP_HOME/0.3.1/bin 'not a directory +'` + +Make extraction fail after the download is verified + + +## `vp upgrade 0.3.1 --silent` + +**Exit code:** 1 + +``` +error: I/O error: File exists (os error 17) +``` + +## `vpt print-file $VP_HOME/0.3.1/keep.txt` + +``` +rollback target +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_rollback_deployed.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_rollback_deployed.md new file mode 100644 index 0000000000..91f0458aea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_rollback_deployed.md @@ -0,0 +1,50 @@ +# command_upgrade_rollback_deployed + +A rollback holds the installation lock while an unmarked target completes in-place setup, so the child must not take that lock. + +## `vpt mkdir external` + + +## `vpt cp $VP_HOME/bin/vp external/vp` + + +## `vpt chmod +x external/vp` + + +## `VP_HOME=${workspace}/home VP_VERSION=rollback-a ./external/vp` + +Deploy the first version + + +## `VP_HOME=${workspace}/home VP_VERSION=rollback-b ./external/vp` + +Deploy the second version, which records the first as the rollback target + + +## `vpt print-file home/.previous-version` + +``` +rollback-a +``` + +## `vpt rm home/rollback-a/bin/.vp-setup-complete` + +The rollback target must run in-place setup during the shim refresh + + +## `VP_HOME=${workspace}/home vp upgrade --rollback` + +A deadlock fails this step by timeout + + +## `vpt print-file home/.previous-version` + +``` +rollback-b +``` + +## `vpt stat-file home/rollback-a/bin/.vp-setup-complete --assert file` + +``` +home/rollback-a/bin/.vp-setup-complete: file +``` From df1d9065b49c5e7b39868e98aa07b08a418fb44d Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Tue, 10 Nov 2026 16:18:12 +0900 Subject: [PATCH 5/6] test(upgrade): serve the failed reinstall payload from a local fixture The failed reinstall case downloaded the published 0.3.1 metadata and native tarball from npm. No shared recording covered those packages, so an npm outage would fail the snapshot with a network error before it reached the directory check. Serve a 999.0.1 payload from the fixture's `mock-manifest.json` and `tarballs/` instead. The metadata carries provenance and the tarball's real integrity, so the upgrade passes verification and fails at the intended `bin` path. The run records only fixture requests. --- .../mock-manifest.json | 88 ++++++++++++++++++ .../command_upgrade_rollback/snapshots.toml | 12 +-- ...failed_reinstall_keeps_existing_version.md | 8 +- .../tarballs/vite-plus-cli-999.0.1.tgz | Bin 0 -> 200 bytes 4 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/mock-manifest.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/tarballs/vite-plus-cli-999.0.1.tgz diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/mock-manifest.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/mock-manifest.json new file mode 100644 index 0000000000..985af23f80 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/mock-manifest.json @@ -0,0 +1,88 @@ +{ + "vite-plus/999.0.1": { + "name": "vite-plus", + "version": "999.0.1", + "dist": { + "tarball": "unused", + "integrity": "sha512-unused" + } + }, + "@voidzero-dev/vite-plus-cli-darwin-arm64/999.0.1": { + "name": "@voidzero-dev/vite-plus-cli-darwin-arm64", + "version": "999.0.1", + "dist": { + "tarball": "{REGISTRY}/@voidzero-dev/vite-plus-cli-darwin-arm64/-/vite-plus-cli-999.0.1.tgz", + "integrity": "sha512-up15UEnVySULq4e34BEg8s9e2A6VLyjwUZrD++6PWXpfwFwkkysfTM21sIsx5mKwwHlfpRRO43C9zySvPMMTCQ==", + "attestations": { + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1" + } + } + } + }, + "@voidzero-dev/vite-plus-cli-darwin-x64/999.0.1": { + "name": "@voidzero-dev/vite-plus-cli-darwin-x64", + "version": "999.0.1", + "dist": { + "tarball": "{REGISTRY}/@voidzero-dev/vite-plus-cli-darwin-x64/-/vite-plus-cli-999.0.1.tgz", + "integrity": "sha512-up15UEnVySULq4e34BEg8s9e2A6VLyjwUZrD++6PWXpfwFwkkysfTM21sIsx5mKwwHlfpRRO43C9zySvPMMTCQ==", + "attestations": { + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1" + } + } + } + }, + "@voidzero-dev/vite-plus-cli-linux-arm64-gnu/999.0.1": { + "name": "@voidzero-dev/vite-plus-cli-linux-arm64-gnu", + "version": "999.0.1", + "dist": { + "tarball": "{REGISTRY}/@voidzero-dev/vite-plus-cli-linux-arm64-gnu/-/vite-plus-cli-999.0.1.tgz", + "integrity": "sha512-up15UEnVySULq4e34BEg8s9e2A6VLyjwUZrD++6PWXpfwFwkkysfTM21sIsx5mKwwHlfpRRO43C9zySvPMMTCQ==", + "attestations": { + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1" + } + } + } + }, + "@voidzero-dev/vite-plus-cli-linux-x64-gnu/999.0.1": { + "name": "@voidzero-dev/vite-plus-cli-linux-x64-gnu", + "version": "999.0.1", + "dist": { + "tarball": "{REGISTRY}/@voidzero-dev/vite-plus-cli-linux-x64-gnu/-/vite-plus-cli-999.0.1.tgz", + "integrity": "sha512-up15UEnVySULq4e34BEg8s9e2A6VLyjwUZrD++6PWXpfwFwkkysfTM21sIsx5mKwwHlfpRRO43C9zySvPMMTCQ==", + "attestations": { + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1" + } + } + } + }, + "@voidzero-dev/vite-plus-cli-linux-arm64-musl/999.0.1": { + "name": "@voidzero-dev/vite-plus-cli-linux-arm64-musl", + "version": "999.0.1", + "dist": { + "tarball": "{REGISTRY}/@voidzero-dev/vite-plus-cli-linux-arm64-musl/-/vite-plus-cli-999.0.1.tgz", + "integrity": "sha512-up15UEnVySULq4e34BEg8s9e2A6VLyjwUZrD++6PWXpfwFwkkysfTM21sIsx5mKwwHlfpRRO43C9zySvPMMTCQ==", + "attestations": { + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1" + } + } + } + }, + "@voidzero-dev/vite-plus-cli-linux-x64-musl/999.0.1": { + "name": "@voidzero-dev/vite-plus-cli-linux-x64-musl", + "version": "999.0.1", + "dist": { + "tarball": "{REGISTRY}/@voidzero-dev/vite-plus-cli-linux-x64-musl/-/vite-plus-cli-999.0.1.tgz", + "integrity": "sha512-up15UEnVySULq4e34BEg8s9e2A6VLyjwUZrD++6PWXpfwFwkkysfTM21sIsx5mKwwHlfpRRO43C9zySvPMMTCQ==", + "attestations": { + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1" + } + } + } + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml index 6be0aaa7c9..0a880e5ae7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml @@ -31,11 +31,11 @@ name = "command_upgrade_failed_reinstall_keeps_existing_version" vp = "global" skip-platforms = ["windows"] comment = "A failed upgrade into an existing version directory keeps that directory, which can be the rollback target." +# mock-manifest.json and tarballs/ serve a local 999.0.1 payload with a valid integrity. steps = [ - { argv = ["vpt", "write-file", "$VP_HOME/0.3.1/keep.txt", "rollback target\n"], snapshot = false }, - { argv = ["vpt", "write-file", "$VP_HOME/0.3.1/bin", "not a directory\n"], comment = "Make extraction fail after the download is verified", snapshot = false }, - # Pin the released payload so the test also runs when the checkout version is unpublished. - # `--silent` keeps the platform suffix and running CLI version out of the snapshot. - { argv = ["vp", "upgrade", "0.3.1", "--silent"], timeout = 120000, continue-on-failure = true }, - ["vpt", "print-file", "$VP_HOME/0.3.1/keep.txt"], + { argv = ["vpt", "write-file", "$VP_HOME/999.0.1/keep.txt", "rollback target\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/999.0.1/bin", "not a directory\n"], comment = "Make extraction fail after the download is verified", snapshot = false }, + # `--silent` keeps the platform suffix out of the snapshot. + { argv = ["vp", "upgrade", "999.0.1", "--silent"], continue-on-failure = true }, + ["vpt", "print-file", "$VP_HOME/999.0.1/keep.txt"], ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_failed_reinstall_keeps_existing_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_failed_reinstall_keeps_existing_version.md index 35a6489db4..4af4cdc700 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_failed_reinstall_keeps_existing_version.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_failed_reinstall_keeps_existing_version.md @@ -2,17 +2,17 @@ A failed upgrade into an existing version directory keeps that directory, which can be the rollback target. -## `vpt write-file $VP_HOME/0.3.1/keep.txt 'rollback target +## `vpt write-file $VP_HOME/999.0.1/keep.txt 'rollback target '` -## `vpt write-file $VP_HOME/0.3.1/bin 'not a directory +## `vpt write-file $VP_HOME/999.0.1/bin 'not a directory '` Make extraction fail after the download is verified -## `vp upgrade 0.3.1 --silent` +## `vp upgrade 999.0.1 --silent` **Exit code:** 1 @@ -20,7 +20,7 @@ Make extraction fail after the download is verified error: I/O error: File exists (os error 17) ``` -## `vpt print-file $VP_HOME/0.3.1/keep.txt` +## `vpt print-file $VP_HOME/999.0.1/keep.txt` ``` rollback target diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/tarballs/vite-plus-cli-999.0.1.tgz b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/tarballs/vite-plus-cli-999.0.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..de50c189de4d5b2e44ce9bd90f8eee86cf6eab7c GIT binary patch literal 200 zcmV;(05|_1iwFSX#PVtY1MQO04uT*QhQ0PF7}ueIptGx9g(+*4i4)|a*1LzxZrs{x z=30yI<{#Su&f%jI)luwa8m$P%xZoU&>6zz!K{zcKXEb9JV1jd6M1Zf^)PuoT(fd}G zo--frN9ZR1Qzi$a+fU!&-y>)2xqm{)%zx&8Km-;$d4)gse~AHrtvE@z1#suIs?W05 zsFp5u%1YEVgF#iJkd?;5%~ Date: Tue, 10 Nov 2026 17:16:37 +0900 Subject: [PATCH 6/6] fix(upgrade): repair an active version whose binary is missing After the installation lock, `vp upgrade` stopped as up to date whenever `current` named the target version. `read_current_version` reads only the link name, so an older CLI running `vp upgrade ` against a `current` link whose binary or directory was missing reported success and left the installation broken. Before the lock change, that command installed the version again. Skip the install only when `current/bin/vp` also exists, as the legacy installer already does. `command_upgrade_repairs_broken_active_version` removes the active binary and upgrades to the same version. The fixture payload has no binary, so the extraction error shows that the upgrade ran instead of returning early. --- .../command_upgrade_rollback/snapshots.toml | 16 ++++++++++ ...d_upgrade_repairs_broken_active_version.md | 30 +++++++++++++++++++ .../vp_global_cli/src/commands/upgrade/mod.rs | 8 ++++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_repairs_broken_active_version.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml index 0a880e5ae7..053824f0ff 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots.toml @@ -39,3 +39,19 @@ steps = [ { argv = ["vp", "upgrade", "999.0.1", "--silent"], continue-on-failure = true }, ["vpt", "print-file", "$VP_HOME/999.0.1/keep.txt"], ] + +[[case]] +name = "command_upgrade_repairs_broken_active_version" +vp = "global" +skip-platforms = ["windows"] +comment = "An upgrade to the version that `current` names still installs it when the active binary is missing." +env = { VP_SKIP_DEPS_INSTALL = "1", VP_NODE_MANAGER = "no", VP_SELF_SETUP_NO_MODIFY_PATH = "1" } +steps = [ + { argv = ["vpt", "mkdir", "external"], snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/vp"], snapshot = false }, + { argv = ["./external/vp"], tty = false, envs = [["VP_HOME", "${workspace}/home"], ["VP_VERSION", "999.0.1"]], comment = "Activate a 999.0.1 directory", snapshot = false }, + { argv = ["vpt", "rm", "-r", "home/999.0.1/bin"], comment = "Break the active installation", snapshot = false }, + # The fixture payload has no `vp` binary, so this error proves that the upgrade ran instead of reporting that it is up to date. + { argv = ["vp", "upgrade", "999.0.1", "--silent"], envs = [["VP_HOME", "${workspace}/home"]], continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_repairs_broken_active_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_repairs_broken_active_version.md new file mode 100644 index 0000000000..8bf22e4b94 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_rollback/snapshots/command_upgrade_repairs_broken_active_version.md @@ -0,0 +1,30 @@ +# command_upgrade_repairs_broken_active_version + +An upgrade to the version that `current` names still installs it when the active binary is missing. + +## `vpt mkdir external` + + +## `vpt cp $VP_HOME/bin/vp external/vp` + + +## `vpt chmod +x external/vp` + + +## `VP_HOME=${workspace}/home VP_VERSION=999.0.1 ./external/vp` + +Activate a 999.0.1 directory + + +## `vpt rm -r home/999.0.1/bin` + +Break the active installation + + +## `VP_HOME=${workspace}/home vp upgrade 999.0.1 --silent` + +**Exit code:** 1 + +``` +error: Upgrade error: Binary not found after extraction. The download may be corrupted. +``` diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index 9da6b504db..23689ca065 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -180,13 +180,19 @@ pub async fn execute(options: UpgradeOptions) -> Result { integrity::verify_integrity(&platform_data, &resolved.platform_integrity)?; // Hold the lock until cleanup finishes. Another process may have activated - // the target version while this one waited. + // the target version while this one waited. A `current` link whose binary + // is missing still needs this install to repair it. let _install_lock = install::lock_install_dir(install_dir).await?; let active_install_dir = install::read_current_version(install_dir).await; if !options.force && active_install_dir .as_deref() .is_some_and(|active| install::is_install_dir_for_version(active, &resolved.version)) + && tokio::fs::try_exists( + install_dir.join("current").join("bin").join(vp_setup::VP_BINARY_NAME), + ) + .await + .unwrap_or(false) { if !options.silent { vp_shared::output::print_stdout_line(format_args!(