diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml new file mode 100644 index 00000000..1488d1f1 --- /dev/null +++ b/.github/workflows/windows-arm64.yml @@ -0,0 +1,37 @@ +name: Windows ARM64 installer + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - ".github/workflows/windows-arm64.yml" + - "scripts/windows_arm64_installer_test.ps1" + - "website/install.ps1" + push: + branches: [master] + paths: + - ".github/workflows/windows-arm64.yml" + - "scripts/windows_arm64_installer_test.ps1" + - "website/install.ps1" + +permissions: + contents: read + +concurrency: + group: windows-arm64-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + installer: + name: installer + runs-on: windows-11-arm + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Test x86_64 fallback installation + shell: powershell + run: .\scripts\windows_arm64_installer_test.ps1 diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 7c8afecb..ac55d0be 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -23,6 +23,7 @@ - Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay. ### Fixed +- The Windows ARM64 installer now waits for x64 emulation to release the verified executable before activating the downloaded release. (#2916) - On Unix, Ctrl-click URL openers are now reaped after they exit, preventing defunct child processes from accumulating on long-running servers. (#2903) - Herdr no longer sends the full OSC 4 palette query burst under WSL, preventing reply fragments from leaking into the shell through ConPTY. (#2440) - Qwen Code panes now use locale-independent terminal-title states and localized confirmation fallbacks, preventing active or blocked turns from appearing idle. (#2756) diff --git a/scripts/windows_arm64_installer_test.ps1 b/scripts/windows_arm64_installer_test.ps1 new file mode 100644 index 00000000..7f5d7174 --- /dev/null +++ b/scripts/windows_arm64_installer_test.ps1 @@ -0,0 +1,49 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$architecture = [System.Runtime.InteropServices.RuntimeInformation, mscorlib]::OSArchitecture.ToString() +Write-Host "OS architecture: $architecture" +Write-Host "Process architecture: $([System.Runtime.InteropServices.RuntimeInformation, mscorlib]::ProcessArchitecture)" +Write-Host "PowerShell: $($PSVersionTable.PSVersion) $($PSVersionTable.PSEdition)" +Write-Host "Windows: $([Environment]::OSVersion.VersionString)" +if ($architecture -ne "Arm64") { + throw "This test requires Windows ARM64, found $architecture." +} + +$root = Join-Path $env:RUNNER_TEMP "herdr-windows-arm64-installer-test" +$env:HERDR_HOME = Join-Path $root "home" +$env:HERDR_INSTALL_DIR = Join-Path $root "bin" +$env:HERDR_CHANNEL = "preview" +Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $root | Out-Null +$installer = (Resolve-Path (Join-Path $PSScriptRoot "..\website\install.ps1")).Path + +$previousErrorActionPreference = $ErrorActionPreference +$ErrorActionPreference = "Continue" +try { + $installerOutput = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer 2>&1 + $installerExitCode = $LASTEXITCODE +} finally { + $ErrorActionPreference = $previousErrorActionPreference +} +$installerOutput | ForEach-Object { Write-Host $_ } +if ($installerExitCode -ne 0) { + throw "The installer failed on Windows ARM64 with exit code $installerExitCode." +} + +$installedHerdr = Join-Path $env:HERDR_INSTALL_DIR "herdr.exe" +if (-not (Test-Path -LiteralPath $installedHerdr -PathType Leaf)) { + throw "The installer exited successfully without activating herdr.exe." +} +& $installedHerdr --version +if ($LASTEXITCODE -ne 0) { + throw "The installed x86_64 Herdr executable failed with exit code $LASTEXITCODE." +} + +$releases = Join-Path $env:HERDR_HOME "packages\standalone\releases" +if (@(Get-ChildItem -LiteralPath $releases -Force -Directory -Filter ".staging.*" -ErrorAction SilentlyContinue).Count -ne 0) { + throw "The installer succeeded but left a staging directory behind." +} + +Write-Host "Windows ARM64 installer test passed." diff --git a/scripts/windows_install_conpty_package_test.ps1 b/scripts/windows_install_conpty_package_test.ps1 index 4b0e73f3..95b31c4a 100644 --- a/scripts/windows_install_conpty_package_test.ps1 +++ b/scripts/windows_install_conpty_package_test.ps1 @@ -212,6 +212,64 @@ try { $manifest | Out-File -LiteralPath $manifestPath -Encoding utf8 $stagedConpty = Join-Path $releasesDir ".staging.$($releaseDir.Name).$PID\conpty\conpty.dll" + + $transientLockState = @{ Handle = $null; Acquired = $false; Released = $false } + $transientLockTimer = New-Object System.Timers.Timer + $transientLockTimer.Interval = 300 + $transientLockTimer.AutoReset = $false + $transientLockSource = "HerdrTransientInstallerLock-$PID" + $transientLockRelease = Register-ObjectEvent ` + -InputObject $transientLockTimer ` + -EventName Elapsed ` + -SourceIdentifier $transientLockSource ` + -MessageData $transientLockState ` + -Action { + $state = $event.MessageData + if ($null -ne $state.Handle) { + $state.Handle.Dispose() + $state.Handle = $null + } + $state.Released = $true + } + $lockStagedFileTransiently = { + if (-not $transientLockState.Acquired) { + $transientLockState.Handle = [System.IO.File]::Open( + $stagedConpty, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read + ) + $transientLockState.Acquired = $true + $transientLockTimer.Start() + } + }.GetNewClosure() + $transientLockBreakpoint = Set-PSBreakpoint -Script $installerPath -Variable "backupDir" -Mode Write -Action $lockStagedFileTransiently + try { + & "$PSScriptRoot\..\website\install.ps1" ` + -ManifestUrl $manifestUrl ` + -InstallDir $installDir ` + -ExpectedBuildId "installer-test" + if (-not $transientLockState.Acquired) { + throw "installer did not acquire the transient staged-file lock" + } + if (-not $transientLockState.Released) { + throw "installer activated the release before the transient lock was released" + } + if (-not (Test-Path -LiteralPath (Join-Path $releaseDir.FullName "conpty\conpty.dll") -PathType Leaf)) { + throw "installer did not repair the release after the transient lock cleared" + } + } finally { + Remove-PSBreakpoint -Breakpoint $transientLockBreakpoint + $transientLockTimer.Stop() + Unregister-Event -SourceIdentifier $transientLockSource -ErrorAction SilentlyContinue + Remove-Job -Id $transientLockRelease.Id -Force -ErrorAction SilentlyContinue + if ($null -ne $transientLockState.Handle) { + $transientLockState.Handle.Dispose() + } + $transientLockTimer.Dispose() + } + + Remove-Item -LiteralPath (Join-Path $releaseDir.FullName "conpty\conpty.dll") -Force $lockState = @{ Handle = $null } $lockStagedFile = { if ($null -eq $lockState.Handle) { diff --git a/website/install.ps1 b/website/install.ps1 index 55b3b381..cef4af04 100644 --- a/website/install.ps1 +++ b/website/install.ps1 @@ -372,6 +372,40 @@ function Test-HerdrReleaseComplete { return $true } +function Move-DirectoryWithRetry { + param( + [string]$Source, + [string]$Destination, + [int]$TimeoutMilliseconds = 5000 + ) + + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) + while ($true) { + try { + [System.IO.Directory]::Move($Source, $Destination) + return + } catch { + $retryable = $false + $exception = $_.Exception + while ($null -ne $exception) { + if ($exception -is [System.IO.IOException] -or + $exception -is [System.UnauthorizedAccessException]) { + $retryable = $true + break + } + $exception = $exception.InnerException + } + if (-not $retryable -or + [DateTime]::UtcNow -ge $deadline -or + -not (Test-Path -LiteralPath $Source -PathType Container) -or + (Test-Path -LiteralPath $Destination)) { + throw + } + Start-Sleep -Milliseconds 100 + } + } +} + function Invoke-WithInstallLock { param( [string]$LockPath, @@ -668,7 +702,7 @@ try { [System.IO.Directory]::Move($releaseDir, $backupDir) } try { - [System.IO.Directory]::Move($stagingDir, $releaseDir) + Move-DirectoryWithRetry -Source $stagingDir -Destination $releaseDir } catch { if ($null -ne $backupDir -and -not (Test-Path -LiteralPath $releaseDir)) { [System.IO.Directory]::Move($backupDir, $releaseDir)