From c454fbc5bdb6785a939908bb44a0b11208d073a8 Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Wed, 26 Aug 2026 12:55:00 +0800 Subject: [PATCH] fix(installer): harden managed Python resolution --- scripts/install.ps1 | 50 +++++++++++-------- tests/install_ps1_fake_uv.py | 5 ++ ...t_install_ps1_managed_python_provenance.py | 29 +++++++++++ 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 74da942c72..07930fd72d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -391,6 +391,7 @@ $PythonVersion = "3.11" # are eligible. Single source of truth shared by Test-Python's fallback and # Resolve-AvailablePythonVersion. $PythonFallbackVersions = @("3.12", "3.13", "3.10") +$PythonFindTimeoutMs = 30000 $NodeVersion = "22" # The npm range the root package.json pins in `engines.npm`. A constant rather # than a manifest read like the POSIX side does: Test-Node runs BEFORE the repo @@ -1187,9 +1188,9 @@ function Initialize-ManagedPythonEnvironment { } function Resolve-AvailablePythonVersion { - # Return the absolute path of the first Hermes-managed interpreter uv can - # find, preferring the requested version and then fallback minors. System - # and application-owned interpreters are deliberately ineligible. + # Return the path and minor version of the first Hermes-managed interpreter + # uv can find, preferring the requested version and then fallback minors. + # System and application-owned interpreters are deliberately ineligible. # # Under Hermes-Setup.exe each stage runs in a fresh powershell.exe. The # venv stage therefore re-resolves both version and provenance rather than @@ -1217,19 +1218,28 @@ function Resolve-AvailablePythonVersion { $startInfo.RedirectStandardError = $true $process.StartInfo = $startInfo if (-not $process.Start()) { continue } - $stdout = $process.StandardOutput.ReadToEnd() - $process.StandardError.ReadToEnd() | Out-Null - $process.WaitForExit() + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($PythonFindTimeoutMs)) { + try { $process.Kill() } catch { } + $process.WaitForExit() + throw "uv python find $ver timed out after $PythonFindTimeoutMs ms" + } + $stdout = $stdoutTask.Result + $stderrTask.Result | Out-Null if ($process.ExitCode -ne 0) { continue } [string]$foundPath = ($stdout.Trim() -split "`r?`n") | Select-Object -Last 1 if ($foundPath) { $absolute = [System.IO.Path]::GetFullPath($foundPath) if ($absolute.StartsWith($managedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - $script:PythonVersion = $ver - return $absolute + return [PSCustomObject]@{ + Path = $absolute + Version = $ver + } } } } catch { + throw "Failed to resolve Hermes-managed Python $ver`: $_" } finally { if ($process) { $process.Dispose() } } @@ -1243,9 +1253,9 @@ function Test-Python { # Only a checkout-private uv-managed interpreter satisfies this stage. try { - $pythonPath = Resolve-AvailablePythonVersion - if ($pythonPath) { - $ver = & $pythonPath --version 2>$null + $resolvedPython = Resolve-AvailablePythonVersion + if ($resolvedPython) { + $ver = & $resolvedPython.Path --version 2>$null Write-Success "Python found: $ver" return $true } @@ -1274,9 +1284,9 @@ function Test-Python { # Check if Python is now available (more reliable than exit code # since uv may return non-zero due to "already installed" etc.) - $pythonPath = Resolve-AvailablePythonVersion - if ($pythonPath) { - $ver = & $pythonPath --version 2>$null + $resolvedPython = Resolve-AvailablePythonVersion + if ($resolvedPython) { + $ver = & $resolvedPython.Path --version 2>$null Write-Success "Python installed: $ver" return $true } @@ -1302,9 +1312,9 @@ function Test-Python { $ErrorActionPreference = "Continue" & $UvCmd python install $fallbackVer --no-bin --no-registry --no-config 2>&1 | Out-Null $ErrorActionPreference = $previousFallbackEAP - $pythonPath = Resolve-AvailablePythonVersion - if ($pythonPath) { - $ver = & $pythonPath --version 2>$null + $resolvedPython = Resolve-AvailablePythonVersion + if ($resolvedPython) { + $ver = & $resolvedPython.Path --version 2>$null Write-Success "Python fallback installed: $ver" return $true } @@ -2557,7 +2567,7 @@ function Install-Venv { throw "Hermes-managed Python is unavailable. Run install.ps1 -Stage python first." } - Write-Info "Creating virtual environment with Python $PythonVersion..." + Write-Info "Creating virtual environment with Python $($resolvedPython.Version)..." Push-Location $InstallDir @@ -2684,7 +2694,7 @@ function Install-Venv { try { $venvStartInfo = New-Object System.Diagnostics.ProcessStartInfo $venvStartInfo.FileName = $UvCmd - $venvStartInfo.Arguments = "venv venv --python `"$resolvedPython`" --managed-python --no-python-downloads --no-config" + $venvStartInfo.Arguments = "venv venv --python `"$($resolvedPython.Path)`" --managed-python --no-python-downloads --no-config" $venvStartInfo.WorkingDirectory = $InstallDir $venvStartInfo.UseShellExecute = $false $venvStartInfo.CreateNoWindow = $true @@ -2799,7 +2809,7 @@ function Install-Venv { } } - Write-Success "Virtual environment ready (Python $PythonVersion)" + Write-Success "Virtual environment ready (Python $($resolvedPython.Version))" } function Get-PendingVenvBackup { diff --git a/tests/install_ps1_fake_uv.py b/tests/install_ps1_fake_uv.py index dfd2cf433b..f603212596 100644 --- a/tests/install_ps1_fake_uv.py +++ b/tests/install_ps1_fake_uv.py @@ -24,6 +24,11 @@ public static class FakeUv { string.Join(" ", args) + Environment.NewLine); if (args.Length >= 2 && args[0] == "python" && args[1] == "find") { + string stderrBytes = Environment.GetEnvironmentVariable( + "FAKE_UV_FIND_STDERR_BYTES"); + if (!string.IsNullOrEmpty(stderrBytes)) { + Console.Error.Write(new string('x', int.Parse(stderrBytes))); + } bool managed = args.Contains("--managed-python"); string availableVersion = Environment.GetEnvironmentVariable( "FAKE_MANAGED_PYTHON_VERSION"); diff --git a/tests/test_install_ps1_managed_python_provenance.py b/tests/test_install_ps1_managed_python_provenance.py index 6f1d62eee0..713c4ecef2 100644 --- a/tests/test_install_ps1_managed_python_provenance.py +++ b/tests/test_install_ps1_managed_python_provenance.py @@ -45,6 +45,7 @@ def _run_venv_stage( capture_output=True, text=True, check=False, + timeout=30, ) @@ -140,6 +141,34 @@ def test_fallback_minor_is_reported_from_resolved_managed_interpreter( assert version.stdout.strip() == "Python 3.12.13" +def test_python_find_drains_large_stderr_without_deadlock(tmp_path: Path) -> None: + powershell = shutil.which("powershell") + if not powershell: + pytest.skip("Windows PowerShell is required") + + hermes_home = tmp_path / "hermes-home" + install_dir = tmp_path / "install" + managed_python = ( + install_dir / ".hermes-runtime" / "python" / "cpython-3.11" / "python.exe" + ) + uv = hermes_home / "bin" / "uv.exe" + uv.parent.mkdir(parents=True) + managed_python.parent.mkdir(parents=True) + compile_fake_uv(powershell, uv) + shutil.copy2(uv, managed_python) + env = { + **os.environ, + "FAKE_UV_LOG": str(tmp_path / "uv.log"), + "FAKE_MANAGED_PYTHON": str(managed_python), + "FAKE_UV_FIND_STDERR_BYTES": str(1024 * 1024), + } + + run = _run_venv_stage(powershell, tmp_path, hermes_home, install_dir, env) + + assert run.returncode == 0, run.stdout + run.stderr + assert "Creating virtual environment with Python 3.11" in run.stdout + + def test_venv_failure_fails_stage_and_restores_existing_environment( tmp_path: Path, ) -> None: