diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ade20cc4de7..0109728b38a 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -185,6 +185,18 @@ function Write-Err { Write-Host "[X] $Message" -ForegroundColor Red } +function Invoke-NativeWithRelaxedErrorAction { + param([scriptblock]$Script) + + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & $Script + } finally { + $ErrorActionPreference = $prevEAP + } +} + # Inspect npm output for a TLS-trust failure and, if found, print actionable # remediation. npm/Node surface corporate MITM proxies and missing root CAs as # "unable to get local issuer certificate" / "self-signed certificate in @@ -1340,7 +1352,7 @@ function Install-Repository { Write-Info "Trying SSH clone..." $env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5" try { - git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlSsh $InstallDir + Invoke-NativeWithRelaxedErrorAction { git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlSsh $InstallDir } if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } } catch { } $env:GIT_SSH_COMMAND = $null @@ -1349,7 +1361,7 @@ function Install-Repository { if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue } Write-Info "SSH failed, trying HTTPS..." try { - git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlHttps $InstallDir + Invoke-NativeWithRelaxedErrorAction { git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlHttps $InstallDir } if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } } catch { } } @@ -1477,8 +1489,20 @@ function Install-Venv { Remove-Item -Recurse -Force "venv" } - # uv creates the venv and pins the Python version in one step - & $UvCmd venv venv --python $PythonVersion + # uv creates the venv and pins the Python version in one step. uv emits + # normal progress such as "Using CPython ..." on stderr; under Windows + # PowerShell 5.1 with EAP=Stop that stderr is a NativeCommandError unless + # we temporarily relax EAP and trust $LASTEXITCODE for real failures. + Invoke-NativeWithRelaxedErrorAction { & $UvCmd venv venv --python $PythonVersion } + # Relaxing EAP above means a *genuine* uv-venv failure (exit != 0) no longer + # aborts on its own. Capture $LASTEXITCODE immediately and fail fast, so the + # `venv` stage can't falsely report success (and Invoke-Stage can't emit + # ok=true) when the venv was never created. + $venvExitCode = $LASTEXITCODE + if ($venvExitCode -ne 0) { + Pop-Location + throw "Failed to create virtual environment (uv venv exited with $venvExitCode)" + } # Neutralize any inherited UV_PYTHON (e.g. $env:UV_PYTHON = "3.14" left in # the user's shell). uv honours UV_PYTHON over an existing venv for the @@ -1548,7 +1572,7 @@ function Install-Dependencies { # in the wrong directory and imports fail with ModuleNotFoundError. # (Mirrors the same flag in scripts/install.sh::install_deps.) $env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv" - & $UvCmd sync --extra all --locked + Invoke-NativeWithRelaxedErrorAction { & $UvCmd sync --extra all --locked } if ($LASTEXITCODE -eq 0) { Write-Success "Main package installed (hash-verified via uv.lock)" $script:InstalledTier = "hash-verified (uv.lock)" @@ -1623,7 +1647,7 @@ except Exception: if (-not $skipPipFallback) { foreach ($tier in $installTiers) { Write-Info "Trying tier: $($tier.Name) ..." - & $UvCmd pip install -e $tier.Spec + Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install -e $tier.Spec } if ($LASTEXITCODE -eq 0) { Write-Success "Main package installed ($($tier.Name))" $script:InstalledTier = $tier.Name diff --git a/tests/test_install_ps1_native_stderr_eap.py b/tests/test_install_ps1_native_stderr_eap.py new file mode 100644 index 00000000000..de99bf22900 --- /dev/null +++ b/tests/test_install_ps1_native_stderr_eap.py @@ -0,0 +1,94 @@ +"""Regression tests for #48352: Windows PowerShell 5.1 native stderr. + +PowerShell 5.1 turns stderr from native commands into ``NativeCommandError`` +records when ``$ErrorActionPreference = "Stop"``. ``scripts/install.ps1`` has a +few git/uv calls where stderr can be normal progress output, so those calls must +run with EAP temporarily relaxed and then inspect ``$LASTEXITCODE``. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" + + +def _install_ps1() -> str: + return INSTALL_PS1.read_text(encoding="utf-8") + + +def _assert_relaxed_call(text: str, command_pattern: str) -> None: + helper_block_pattern = ( + r"Invoke-NativeWithRelaxedErrorAction\s*\{[^}]*" + + command_pattern + + r"[^}]*\}" + ) + inline_pattern = ( + r"\$ErrorActionPreference\s*=\s*\"Continue\"[\s\S]{0,900}?" + + command_pattern + ) + assert re.search(helper_block_pattern, text) or re.search(inline_pattern, text), ( + f"install.ps1 must relax ErrorActionPreference around {command_pattern}" + ) + + +def test_repository_stage_relieves_eap_for_ssh_and_https_git_clone() -> None: + text = _install_ps1() + assert "function Invoke-NativeWithRelaxedErrorAction" in text + _assert_relaxed_call( + text, + r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlSsh \$InstallDir", + ) + _assert_relaxed_call( + text, + r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlHttps \$InstallDir", + ) + + +def test_uv_venv_and_dependency_installs_relax_eap() -> None: + text = _install_ps1() + _assert_relaxed_call(text, r"& \$UvCmd venv venv --python \$PythonVersion") + _assert_relaxed_call(text, r"& \$UvCmd sync --extra all --locked") + _assert_relaxed_call(text, r"& \$UvCmd pip install -e \$tier\.Spec") + + +def test_uv_venv_failure_is_not_swallowed_after_eap_relax() -> None: + """Relaxing EAP must not let a genuine `uv venv` failure pass as success. + + Once EAP is relaxed, a real non-zero `uv venv` exit no longer aborts on its + own, so install.ps1 must capture $LASTEXITCODE right after the call and fail + fast — otherwise the `venv` stage falsely reports success (Invoke-Stage emits + ok=true) when no venv was created. Regression guard for the gap caught while + reviewing #48372 (the explicit check originally proposed in #48463). + """ + text = _install_ps1() + # The uv-venv invocation, then an exit-code capture, then a throw — all + # within a small window after the relaxed call. + guard = re.search( + r"& \$UvCmd venv venv --python \$PythonVersion[\s\S]{0,400}?" + r"\$LASTEXITCODE[\s\S]{0,200}?" + r"-ne 0[\s\S]{0,200}?throw", + text, + ) + assert guard is not None, ( + "install.ps1 must capture uv venv's exit code and throw on failure after " + "relaxing ErrorActionPreference, so a genuine venv-creation failure isn't " + "reported as a successful stage" + ) + + +def test_native_eap_helper_always_restores_previous_preference() -> None: + text = _install_ps1() + m = re.search( + r"function Invoke-NativeWithRelaxedErrorAction \{(?P[\s\S]*?)^\}", + text, + re.MULTILINE, + ) + assert m is not None, "expected a shared helper for NativeCommandError-safe calls" + body = m.group("body") + assert "$prevEAP = $ErrorActionPreference" in body + assert '$ErrorActionPreference = "Continue"' in body + assert "finally" in body + assert "$ErrorActionPreference = $prevEAP" in body