fix(installer): harden managed Python resolution

This commit is contained in:
fangliquanflq
2026-08-26 12:55:00 +08:00
committed by Teknium
parent a2895e9968
commit c454fbc5bd
3 changed files with 64 additions and 20 deletions

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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: