diff --git a/.github/scripts/tests/test_cua_driver_release_wiring.py b/.github/scripts/tests/test_cua_driver_release_wiring.py index 02183ebd38..16e7df40cf 100644 --- a/.github/scripts/tests/test_cua_driver_release_wiring.py +++ b/.github/scripts/tests/test_cua_driver_release_wiring.py @@ -469,7 +469,7 @@ def test_driver_tag_build_cannot_publish_before_manual_e2e_gate(self) -> None: self.assertIn("CUA_DRIVER_LOCAL_HOME: ${{ runner.temp }}/cua-driver-local-home", linux) windows = self.read(".github/workflows/e2e-rust-windows.yml") - self.assertIn('name: "Windows / install-local.ps1 smoke"', windows) + self.assertIn('name: "Windows / installer and update smoke"', windows) self.assertIn("install-local.ps1 -NoAutoStart -NoPathUpdate", windows) self.assertIn('CUA_DRIVER_LOCAL_HOME = Join-Path $env:RUNNER_TEMP', windows) diff --git a/.github/workflows/e2e-rust-windows.yml b/.github/workflows/e2e-rust-windows.yml index 7e8d668eaf..dadaebb9fa 100644 --- a/.github/workflows/e2e-rust-windows.yml +++ b/.github/workflows/e2e-rust-windows.yml @@ -167,7 +167,7 @@ jobs: installer: if: inputs.lane == 'all' || inputs.lane == 'installer' - name: "Windows / install-local.ps1 smoke" + name: "Windows / installer and update smoke" needs: source runs-on: windows-latest timeout-minutes: 45 @@ -181,6 +181,47 @@ jobs: - name: Verify installer runner is interactive shell: pwsh run: .\scripts\ci\windows\verify-user-session.ps1 + - name: Run Windows installer regression contracts + shell: pwsh + run: .\libs\cua-driver\scripts\tests\install-windows-regression.ps1 + - name: Verify released install and legacy migration preserve state + shell: pwsh + run: | + $savedUserProfile = $env:USERPROFILE + $savedLocalAppData = $env:LOCALAPPDATA + $testRoot = Join-Path $env:RUNNER_TEMP "cua-driver-release-installer" + $env:USERPROFILE = Join-Path $testRoot "profile" + $env:LOCALAPPDATA = Join-Path $testRoot "localappdata" + $legacyHome = Join-Path $env:USERPROFILE ".cua-driver-rs" + $modernHome = Join-Path $env:USERPROFILE ".cua-driver" + $installed = Join-Path $env:LOCALAPPDATA "Programs\Cua\cua-driver\bin\cua-driver.exe" + try { + New-Item -ItemType Directory -Force -Path $legacyHome | Out-Null + Set-Content -LiteralPath (Join-Path $legacyHome "version_check.json") -Value "{}" + Set-Content -LiteralPath (Join-Path $legacyHome ".telemetry_id") -Value "synthetic-e2e-id" + + $firstOutput = (& .\libs\cua-driver\scripts\install.ps1 -NoPathUpdate *>&1 | Out-String) + $firstOutput | Set-Content -LiteralPath (Join-Path $env:RUNNER_TEMP "cua-driver-release-install.txt") + if ($firstOutput -match "detected legacy install layout") { throw "cache-only home was treated as legacy" } + if (-not (Test-Path -LiteralPath $installed)) { throw "missing installed binary: $installed" } + if (-not (Test-Path -LiteralPath (Join-Path $modernHome "packages\current"))) { throw "missing current package junction" } + & schtasks.exe /Query /TN "cua-driver-serve" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "install did not register cua-driver-serve" } + + New-Item -ItemType Directory -Force -Path (Join-Path $legacyHome "packages") | Out-Null + $secondOutput = (& .\libs\cua-driver\scripts\install.ps1 -NoPathUpdate *>&1 | Out-String) + $secondOutput | Set-Content -LiteralPath (Join-Path $env:RUNNER_TEMP "cua-driver-release-reinstall.txt") + if ($secondOutput -notmatch "detected legacy install layout") { throw "true legacy marker was not detected" } + if (-not (Test-Path -LiteralPath $installed)) { throw "reinstall removed the installed binary" } + & schtasks.exe /Query /TN "cua-driver-serve" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "legacy migration did not restore cua-driver-serve" } + } + finally { + & schtasks.exe /End /TN "cua-driver-serve" 2>$null | Out-Null + & schtasks.exe /Delete /TN "cua-driver-serve" /F 2>$null | Out-Null + $env:USERPROFILE = $savedUserProfile + $env:LOCALAPPDATA = $savedLocalAppData + } - name: Install into an isolated local namespace shell: pwsh env: @@ -236,6 +277,8 @@ jobs: ${{ runner.temp }}/cua-driver-local-config.json ${{ runner.temp }}/cua-driver-local-daemon.out.log ${{ runner.temp }}/cua-driver-local-daemon.err.log + ${{ runner.temp }}/cua-driver-release-install.txt + ${{ runner.temp }}/cua-driver-release-reinstall.txt if-no-files-found: ignore retention-days: 14 diff --git a/libs/cua-driver/scripts/install.ps1 b/libs/cua-driver/scripts/install.ps1 index 5db58656a6..2731ccf110 100644 --- a/libs/cua-driver/scripts/install.ps1 +++ b/libs/cua-driver/scripts/install.ps1 @@ -1133,6 +1133,22 @@ Write-Step "cua-driver-rs installer (Windows)" Write-Step " install dir : $VisibleBinDir" Write-Step " package home: $HomeDir" +function Get-AncestorProcessIds { + # PIDs of this process and its ancestors, so cleanup never kills the caller. + # `cua-driver update --apply` runs the installer as a child of cua-driver.exe. + $ids = @() + $seen = @{} + $currentPid = $PID + while ($currentPid -and -not $seen.ContainsKey([int]$currentPid)) { + $seen[[int]$currentPid] = $true + $ids += $currentPid + $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$currentPid" -ErrorAction SilentlyContinue + if (-not $proc -or -not $proc.ParentProcessId) { break } + $currentPid = $proc.ParentProcessId + } + return $ids +} + function Remove-LegacyInstall { # Best-effort cleanup of v0.2.13-and-earlier install paths. Runs before # any new install when default paths are in use (so users who override @@ -1143,8 +1159,14 @@ function Remove-LegacyInstall { if ($env:CUA_DRIVER_RS_INSTALL_DIR -or $env:CUA_DRIVER_RS_HOME) { return } - $hasLegacy = (Test-Path -LiteralPath $LegacyVisibleBinDir) -or ` - (Test-Path -LiteralPath $LegacyHomeDir) + # A bare `~/.cua-driver-rs` is NOT evidence of a legacy install: the current + # version still writes its update-check cache and telemetry ids there + # (see crates/cua-driver/src/version_check.rs, HOME_SUBDIRECTORY). Requiring + # an actual legacy artifact keeps `update --apply` from walking into this + # branch on every single run once the update banner has been checked once. + $hasLegacyHome = (Test-Path -LiteralPath (Join-Path $LegacyHomeDir 'packages')) -or ` + (Test-Path -LiteralPath (Join-Path $LegacyHomeDir 'bin')) + $hasLegacy = (Test-Path -LiteralPath $LegacyVisibleBinDir) -or $hasLegacyHome if (-not $hasLegacy) { return } Write-Step "detected legacy install layout (v0.2.13 or earlier); migrating to Cua\cua-driver" @@ -1168,6 +1190,11 @@ function Remove-LegacyInstall { # c. Stop-Process last — catches anything taskkill missed. $prevEAP = $ErrorActionPreference $ErrorActionPreference = 'Continue' + # Never kill the process tree we are running inside: when the installer is + # launched by `cua-driver update --apply`, our own parent IS a cua-driver.exe, + # and an unfiltered kill terminates the update mid-flight (the script dies + # right after the step banner above). Both cleanup passes below skip these. + $ancestorPids = @(Get-AncestorProcessIds) try { # Ends the running task instance. Returns non-zero when the task # isn't running or doesn't exist, both of which we swallow. @@ -1175,14 +1202,20 @@ function Remove-LegacyInstall { Start-Sleep -Milliseconds 250 # Force-kill via taskkill — handles High-IL processes that # Stop-Process can't touch from a Medium-IL caller. - & taskkill.exe /F /IM "cua-driver.exe" /T 2>$null | Out-Null - & taskkill.exe /F /IM "cua-driver-uia.exe" /T 2>$null | Out-Null + $selfFilters = @() + foreach ($ancestorPid in $ancestorPids) { + $selfFilters += '/FI' + $selfFilters += "PID ne $ancestorPid" + } + & taskkill.exe /F /IM "cua-driver.exe" /T @selfFilters 2>$null | Out-Null + & taskkill.exe /F /IM "cua-driver-uia.exe" /T @selfFilters 2>$null | Out-Null } finally { $ErrorActionPreference = $prevEAP } $procs = Get-Process -Name "cua-driver","cua-driver-uia" -ErrorAction SilentlyContinue if ($procs) { foreach ($p in $procs) { + if ($ancestorPids -contains $p.Id) { continue } try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {} } } diff --git a/libs/cua-driver/scripts/tests/install-windows-regression.ps1 b/libs/cua-driver/scripts/tests/install-windows-regression.ps1 new file mode 100644 index 0000000000..0329e3d59a --- /dev/null +++ b/libs/cua-driver/scripts/tests/install-windows-regression.ps1 @@ -0,0 +1,164 @@ +[CmdletBinding()] +param( + [string]$InstallerPath = (Join-Path (Split-Path -Parent $PSScriptRoot) "install.ps1") +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Assert-True { + param( + [Parameter(Mandatory = $true)][bool]$Condition, + [Parameter(Mandatory = $true)][string]$Message + ) + if (-not $Condition) { throw $Message } +} + +function Import-InstallerFunction { + param( + [Parameter(Mandatory = $true)]$Ast, + [Parameter(Mandatory = $true)][string]$Name + ) + $definition = $Ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $Name + }, $true) | Select-Object -First 1 + if (-not $definition) { throw "function $Name not found in $InstallerPath" } + $scriptDefinition = $definition.Extent.Text -replace "^function\s+$([regex]::Escape($Name))", "function script:$Name" + . ([scriptblock]::Create($scriptDefinition)) +} + +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $InstallerPath, + [ref]$tokens, + [ref]$parseErrors) +if ($parseErrors.Count -ne 0) { + throw "install.ps1 parse errors: $($parseErrors -join '; ')" +} + +Import-InstallerFunction -Ast $ast -Name "Get-AncestorProcessIds" +Import-InstallerFunction -Ast $ast -Name "Remove-LegacyInstall" + +$script:Steps = @() +$script:TaskkillCalls = @() +$script:ScheduledTaskCalls = @() +$script:StoppedProcessIds = @() +$script:Processes = @() +$script:ParentByPid = @{} + +function Write-Step { param($Message); $script:Steps += [string]$Message } +function Start-Sleep { [CmdletBinding()] param([int]$Milliseconds, [int]$Seconds) } +function taskkill.exe { $script:TaskkillCalls += ,@($args); $global:LASTEXITCODE = 0 } +function schtasks.exe { $script:ScheduledTaskCalls += ,@($args); $global:LASTEXITCODE = 0 } +function Get-CimInstance { + [CmdletBinding()] + param([string]$ClassName, [string]$Filter) + $processId = [int]($Filter -replace '^ProcessId=', '') + if (-not $script:ParentByPid.ContainsKey($processId)) { return $null } + [pscustomobject]@{ + ProcessId = $processId + ParentProcessId = $script:ParentByPid[$processId] + } +} +function Get-Process { + [CmdletBinding()] + param([string[]]$Name) + return $script:Processes +} +function Stop-Process { + [CmdletBinding()] + param([int]$Id, [switch]$Force) + $script:StoppedProcessIds += $Id +} + +$savedInstallDir = $env:CUA_DRIVER_RS_INSTALL_DIR +$savedHome = $env:CUA_DRIVER_RS_HOME +$env:CUA_DRIVER_RS_INSTALL_DIR = $null +$env:CUA_DRIVER_RS_HOME = $null + +try { + $root = Join-Path ([System.IO.Path]::GetTempPath()) ("cua-driver-installer-regression-" + [guid]::NewGuid().ToString("N")) + $HomeDir = Join-Path $root "modern-home" + $LegacyHomeDir = Join-Path $root "profile\.cua-driver-rs" + $LegacyVendorDir = Join-Path $root "localappdata\Programs\trycua" + $LegacyVisibleBinDir = Join-Path $LegacyVendorDir "cua-driver-rs\bin" + + # Current releases write cache and telemetry state here. Neither file is a + # legacy installation marker, and migration must leave both untouched. + New-Item -ItemType Directory -Force -Path $LegacyHomeDir | Out-Null + Set-Content -LiteralPath (Join-Path $LegacyHomeDir "version_check.json") -Value "{}" + Set-Content -LiteralPath (Join-Path $LegacyHomeDir ".telemetry_id") -Value "synthetic-test-id" + Remove-LegacyInstall + Assert-True ($script:Steps.Count -eq 0) "cache/telemetry-only home was detected as legacy" + Assert-True ($script:TaskkillCalls.Count -eq 0) "cache/telemetry-only home triggered process cleanup" + Assert-True (Test-Path -LiteralPath $LegacyHomeDir) "cache/telemetry-only home was removed" + + # Every supported on-disk legacy marker independently enters migration. + foreach ($marker in @("packages", "bin", "visible-bin")) { + $scenarioRoot = Join-Path $root $marker + $HomeDir = Join-Path $scenarioRoot "modern-home" + $LegacyHomeDir = Join-Path $scenarioRoot "profile\.cua-driver-rs" + $LegacyVendorDir = Join-Path $scenarioRoot "localappdata\Programs\trycua" + $LegacyVisibleBinDir = Join-Path $LegacyVendorDir "cua-driver-rs\bin" + if ($marker -eq "visible-bin") { + New-Item -ItemType Directory -Force -Path $LegacyVisibleBinDir | Out-Null + } else { + New-Item -ItemType Directory -Force -Path (Join-Path $LegacyHomeDir $marker) | Out-Null + } + $script:Steps = @() + $script:TaskkillCalls = @() + $script:ScheduledTaskCalls = @() + $script:Processes = @() + $script:StoppedProcessIds = @() + $script:ParentByPid = @{} + $script:ParentByPid[[int]$PID] = 0 + Remove-LegacyInstall + Assert-True ($script:Steps -contains "detected legacy install layout (v0.2.13 or earlier); migrating to Cua\cua-driver") ` + "$marker was not detected as a legacy marker" + } + + # Model update --apply: PowerShell is a child of cua-driver.exe, which in + # turn has another ancestor. Both must be excluded from taskkill and the + # Stop-Process backstop, while an unrelated daemon must still be stopped. + $HomeDir = Join-Path $root "process-tree\modern-home" + $LegacyHomeDir = Join-Path $root "process-tree\profile\.cua-driver-rs" + $LegacyVendorDir = Join-Path $root "process-tree\localappdata\Programs\trycua" + $LegacyVisibleBinDir = Join-Path $LegacyVendorDir "cua-driver-rs\bin" + New-Item -ItemType Directory -Force -Path (Join-Path $LegacyHomeDir "packages") | Out-Null + $script:Steps = @() + $script:TaskkillCalls = @() + $script:ScheduledTaskCalls = @() + $script:StoppedProcessIds = @() + $script:ParentByPid = @{} + $script:ParentByPid[[int]$PID] = 4100 + $script:ParentByPid[4100] = 4200 + $script:ParentByPid[4200] = 0 + $script:Processes = @( + [pscustomobject]@{ Id = 4100; ProcessName = "cua-driver" }, + [pscustomobject]@{ Id = 4300; ProcessName = "cua-driver" } + ) + + $ancestors = @(Get-AncestorProcessIds) + Assert-True (($ancestors -join ',') -eq "$PID,4100,4200") "ancestor discovery did not traverse the complete synthetic chain" + Remove-LegacyInstall + + Assert-True ($script:TaskkillCalls.Count -eq 2) "expected taskkill calls for driver and UIA processes" + foreach ($call in $script:TaskkillCalls) { + $commandLine = $call -join ' ' + foreach ($protectedPid in @($PID, 4100, 4200)) { + Assert-True ($commandLine -match "PID ne $protectedPid(?: |$)") ` + "taskkill did not exclude protected ancestor PID $protectedPid" + } + } + Assert-True (-not ($script:StoppedProcessIds -contains 4100)) "Stop-Process targeted the cua-driver launcher" + Assert-True ($script:StoppedProcessIds -contains 4300) "unrelated cua-driver daemon was not stopped" + + Write-Host "Windows installer legacy/update regression checks passed." +} +finally { + $env:CUA_DRIVER_RS_INSTALL_DIR = $savedInstallDir + $env:CUA_DRIVER_RS_HOME = $savedHome +}