fix(plugins): keep version upgrades additive (#23356)

## Why

Windows can reject plugin cache upgrades when a running MCP server still
has its working directory inside the currently active plugin version.
The existing cache refresh path replaces
`plugins/cache/<marketplace>/<plugin>` as a whole, so a live handle
under the old version can make an otherwise ordinary version bump fail.

This PR keeps the existing plugin-selection model intact while making
version bumps less disruptive.

## What changed

- When installing a new version beside an existing plugin cache root,
move only the staged version directory into place instead of replacing
the whole plugin root.
- Best-effort prune older sibling version directories after the new
version is activated.
- Preserve the existing whole-root replacement path for first installs
and same-version refreshes.
- Add regression coverage for upgrading from `1.0.0` to `2.0.0` without
replacing the plugin root.

## Verification

- `cargo test -p codex-core-plugins install_with_new_version`
- `cargo fmt --package codex-core-plugins --check`
This commit is contained in:
iceweasel-oai
2026-05-18 21:02:30 -07:00
committed by GitHub
Unverified
parent 9e9a62dc28
commit d3d38159ed
4 changed files with 131 additions and 2 deletions
+58 -1
View File
@@ -4,8 +4,10 @@ use codex_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::find_plugin_manifest_path;
use semver::Version;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::cmp::Ordering;
use std::fs;
use std::io;
use std::path::Path;
@@ -75,7 +77,7 @@ impl PluginStore {
})
.filter(|version| validate_plugin_version_segment(version).is_ok())
.collect::<Vec<_>>();
discovered_versions.sort_unstable();
discovered_versions.sort_unstable_by(|left, right| compare_plugin_versions(left, right));
if discovered_versions.is_empty() {
None
} else if discovered_versions
@@ -286,6 +288,15 @@ fn replace_plugin_root_atomically(
let staged_version_root = staged_root.join(plugin_version);
copy_dir_recursive(source, &staged_version_root)?;
let target_version_root = target_root.join(plugin_version);
if target_root.exists() && !target_version_root.exists() {
fs::rename(&staged_version_root, &target_version_root).map_err(|err| {
PluginStoreError::io("failed to activate updated plugin cache version", err)
})?;
remove_old_plugin_versions(target_root, plugin_version)?;
return Ok(());
}
if target_root.exists() {
let backup_dir = tempfile::Builder::new()
.prefix("plugin-backup-")
@@ -322,6 +333,52 @@ fn replace_plugin_root_atomically(
Ok(())
}
fn remove_old_plugin_versions(
target_root: &Path,
plugin_version: &str,
) -> Result<(), PluginStoreError> {
let Ok(entries) = fs::read_dir(target_root) else {
return Ok(());
};
for entry in entries.filter_map(Result::ok) {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let Ok(version) = entry.file_name().into_string() else {
continue;
};
if version == plugin_version || validate_plugin_version_segment(&version).is_err() {
continue;
}
if fs::remove_dir_all(entry.path()).is_err()
&& old_plugin_version_would_stay_active(&version, plugin_version)
{
return Err(PluginStoreError::Invalid(format!(
"failed to activate updated plugin cache version `{plugin_version}` while `{version}` remains active"
)));
}
}
Ok(())
}
fn old_plugin_version_would_stay_active(old_version: &str, new_version: &str) -> bool {
old_version == DEFAULT_PLUGIN_VERSION
|| compare_plugin_versions(old_version, new_version).is_gt()
}
fn compare_plugin_versions(left: &str, right: &str) -> Ordering {
match (Version::parse(left), Version::parse(right)) {
(Ok(left), Ok(right)) => left.cmp(&right),
_ => left.cmp(right),
}
}
fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
fs::create_dir_all(target)
.map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?;