diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 9fda30d501..314dfa7f76 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -1931,8 +1931,17 @@ pub trait Backend: Debug + Send + Sync { tv: &ToolVersion, script: &str, ) -> eyre::Result<()> { + // Resolve the hook against the exact version just installed (locked) rather + // than the request's runtime symlink: for a fuzzy request (e.g. `version = "3"`) + // the runtime symlink (installs/python/3) still points at the previous version + // until all installs finish and symlinks are rebuilt. Without this, both the + // hook's env (exec_env, e.g. a backend deriving PYTHONHOME/JAVA_HOME from + // runtime_path()) and its PATH (list_bin_paths, e.g. `pip`) would resolve to a + // stale install (#10347). + let tv_exact = tv.clone().with_locked(); + // Get pre-tools environment variables from config - let mut env_vars = self.exec_env(&ctx.config, &ctx.ts, tv).await?; + let mut env_vars = self.exec_env(&ctx.config, &ctx.ts, &tv_exact).await?; // Add pre-tools environment variables from config if available if let Some(config_env) = ctx.config.env_maybe() { @@ -1944,8 +1953,8 @@ pub trait Backend: Debug + Send + Sync { // Use the backend's list_bin_paths to get the correct binary directories // instead of hardcoding install_path/bin, which may not match the actual - // binary location for backends like aqua - let bin_paths = self.list_bin_paths(&ctx.config, tv).await?; + // binary location for backends like aqua. + let bin_paths = self.list_bin_paths(&ctx.config, &tv_exact).await?; let mut path_env = PathEnv::from_iter(env::PATH.clone()); for p in bin_paths { path_env.add(p); diff --git a/src/toolset/tool_version.rs b/src/toolset/tool_version.rs index f4077b7469..2f0aad882b 100644 --- a/src/toolset/tool_version.rs +++ b/src/toolset/tool_version.rs @@ -122,6 +122,15 @@ impl ToolVersion { self } + /// Returns a copy locked to the exact resolved version, so `runtime_path()` + /// resolves to `install_path()` rather than a fuzzy runtime symlink (e.g. + /// `installs/python/3`). Used during postinstall so the hook sees the version + /// just installed, not a stale symlink target (#10347). + pub(crate) fn with_locked(mut self) -> Self { + self.locked = true; + self + } + fn from_lockfile(request: ToolRequest, lt: LockfileTool) -> Self { let mut tv = Self::new(request, lt.version); tv.locked = true; @@ -802,6 +811,65 @@ mod tests { Ok(()) } + #[test] + fn with_locked_runtime_path_uses_install_path_for_fuzzy_request() -> Result<()> { + reset_install_path_cache(); + + let temp_dir = tempfile::tempdir()?; + let short = format!( + "dummy-locked-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let mut backend = BackendArg::new_raw( + short.clone(), + None, + short, + None, + BackendResolution::new(false), + ); + backend.installs_path = temp_dir.path().join("installs").join("dummy"); + + let install_path = backend.installs_path.join("3.14.6"); + fs::create_dir_all(install_path.join("bin"))?; + + // Reproduce the stale state during `mise up` (#10347): the fuzzy runtime + // symlink installs/dummy/3 still points at a previous version (3.13.9) while + // 3.14.6 is the version just installed -- runtime symlinks are only rebuilt + // after all installs finish. is_runtime_symlink() requires a "./" target; on + // Windows runtime symlinks are stored as a file containing the target. + let old_path = backend.installs_path.join("3.13.9"); + fs::create_dir_all(old_path.join("bin"))?; + let runtime_link = backend.installs_path.join("3"); + #[cfg(unix)] + std::os::unix::fs::symlink("./3.13.9", &runtime_link)?; + #[cfg(windows)] + fs::write(&runtime_link, "./3.13.9")?; + + // Fuzzy request ("3") resolved to a concrete version ("3.14.6"). + let request = ToolRequest::Version { + backend: Arc::new(backend), + version: "3".into(), + options: ToolVersionOptions::default(), + source: ToolSource::Argument, + }; + let tv = ToolVersion::new(request, "3.14.6".into()); + + // Without locking, runtime_path() follows the stale fuzzy runtime symlink, so + // it does NOT resolve to the version just installed -- the bug behavior. (This + // also self-checks the stale-symlink setup above.) + assert_ne!(tv.runtime_path(), install_path); + + // with_locked() pins runtime_path() to the exact install just made, not the + // fuzzy runtime symlink, so the postinstall hook sees 3.14.6 (#10347). + assert_eq!(tv.clone().with_locked().runtime_path(), install_path); + assert_eq!(tv.clone().with_locked().runtime_path(), tv.install_path()); + + Ok(()) + } + /// Regression test for https://github.com/jdx/mise/discussions/9526 /// /// `install_path()` must not cache a path that does not yet exist. If it