diff --git a/desktop/scripts/windows-installer-smoke.ps1 b/desktop/scripts/windows-installer-smoke.ps1 index 00d45388..71c0290d 100644 --- a/desktop/scripts/windows-installer-smoke.ps1 +++ b/desktop/scripts/windows-installer-smoke.ps1 @@ -55,6 +55,11 @@ function Invoke-ProcessExpectFailure { } if ($PSBoundParameters.ContainsKey('ExpectedExitCode')) { if ($process.ExitCode -ne $ExpectedExitCode) { + # 22 means "a matching process is running" and 20 means "legacy recovery + # refused to continue". Getting 22 where 20 was expected says the + # installer stopped before it ever reached recovery, so the process list + # is the evidence that separates a real regression from a dirty runner. + Write-InstallerFallbackProcessDiagnostic -Context "$Stage (expected $ExpectedExitCode, got $($process.ExitCode))" throw "$Stage expected process exit code $ExpectedExitCode, received $($process.ExitCode)." } } elseif ($process.ExitCode -eq 0) { @@ -100,6 +105,93 @@ function Test-IsProcessElevated { } } +# The exact image names installer.nsh falls back to when PowerShell is +# unavailable (see CcHahaFindInstallProcess's findstr list). Without a CLR the +# installer cannot resolve paths, so it matches on these names alone and any +# process carrying one -- whoever started it -- reads as "the app is still +# running". Keep this in sync with installer.nsh. +$installerFallbackImageNames = @( + 'Claude Code Haha.exe', + 'claude-sidecar-x86_64-pc-windows-msvc.exe', + 'claude-sidecar-aarch64-pc-windows-msvc.exe', + 'claude-sidecar.exe', + 'OpenConsole.exe', + 'winpty-agent.exe', + 'rg.exe' +) + +function Get-InstallerFallbackProcesses { + # Not $matches: that is an automatic variable holding -match results. + $running = @() + foreach ($imageName in $installerFallbackImageNames) { + $processName = [IO.Path]::GetFileNameWithoutExtension($imageName) + foreach ($process in @(Get-Process -Name $processName -ErrorAction SilentlyContinue)) { + $running += $process + } + } + return $running +} + +# The no-CLR stages assert an exit code that says *why* the installer stopped, +# so a stray same-named process anywhere on the runner silently rewrites the +# answer. Print what the fallback would have seen; a stage that expected 22 and +# got 22 for the wrong reason looks identical to a pass without this. +function Write-InstallerFallbackProcessDiagnostic { + param([Parameter(Mandatory = $true)][string]$Context) + + [Console]::Out.WriteLine("--- $Context : processes matching the installer's no-CLR image-name fallback ---") + $running = @(Get-InstallerFallbackProcesses) + if ($running.Count -eq 0) { + [Console]::Out.WriteLine(' (none)') + } else { + foreach ($process in $running) { + $processPath = '' + try { + if ($process.Path) { $processPath = $process.Path } + } catch { + # A process owned by another user denies path access; the name is the + # part that matters here, since the name is all the fallback matches on. + } + [Console]::Out.WriteLine(" PID=$($process.Id); Name=$($process.ProcessName); Path=$processPath") + } + } + [Console]::Out.WriteLine('--- end ---') +} + +# Earlier steps in this same CI job start sidecars and helpers, and this script +# deliberately leaves probe processes running for the stages that assert on +# them. Anything still alive when a later stage runs would be attributed to that +# stage instead. Settle the field before a stage whose expected exit code +# depends on nothing matching. +function Clear-InstallerFallbackProcesses { + param( + [Parameter(Mandatory = $true)][string]$Context, + [int]$TimeoutSeconds = 30 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ($true) { + $running = @(Get-InstallerFallbackProcesses) + if ($running.Count -eq 0) { return } + + if ((Get-Date) -ge $deadline) { + # Deliberately not a throw. Anything surviving this is outside the + # script's control (a runner-owned process of the same name), and failing + # here would replace the stage's own failure -- which now prints the same + # process list -- with a less informative one. Report and let the stage + # speak for itself. + [Console]::Out.WriteLine("WARNING: $Context could not clear $($running.Count) matching process(es) within ${TimeoutSeconds}s.") + Write-InstallerFallbackProcessDiagnostic -Context "$Context (still present after ${TimeoutSeconds}s)" + return + } + + foreach ($process in $running) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Milliseconds 250 + } +} + function Invoke-ProcessHelperExpectExit { param( [Parameter(Mandatory = $true)][string]$Stage, @@ -160,6 +252,11 @@ try { Remove-Item Env:CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue Remove-Item Env:CC_HAHA_APP_PORTABLE_DIR -ErrorAction SilentlyContinue + # Baseline before anything here starts a process. Whatever this prints was put + # there by earlier steps of the job or by the runner image, and it is exactly + # what the no-CLR stages would misattribute to themselves. + Write-InstallerFallbackProcessDiagnostic -Context 'Runner baseline before fresh install' + Invoke-CheckedProcess -FilePath $installer -Stage 'Fresh install' -Arguments @('/S', '/currentuser', "/D=$installDir") if (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) { throw "Fresh install did not create the application executable: $appExe" @@ -218,6 +315,13 @@ try { $bundledHelperProcess.Dispose() $bundledHelperProcess = $null + # WaitForExit covers the PID this script started; the fallback matches on the + # image name, so it also sees anything left over from earlier steps of this + # job (the compiled-sidecar smoke starts 20 sidecars) and any child the probe + # spawned. The next two stages expect 20 -- "recovery refused" -- which they can + # only reach if nothing matches the name list first. + Clear-InstallerFallbackProcesses -Context 'Before no-CLR default-mode reinstall' + $env:COMPLUS_Version = 'v0.0.0-test-invalid-clr' if (Test-IsProcessElevated) { Invoke-ProcessExpectFailure -FilePath $installer -Stage 'Elevated default-mode reinstall without CLR' -ExpectedExitCode 20 -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir") @@ -233,6 +337,10 @@ try { $legacySentinel = Join-Path $legacyDir 'settings.json' New-Item -ItemType Directory -Path $legacyDir -Force | Out-Null Set-Content -LiteralPath $legacySentinel -Value 'must-survive-failed-upgrade' -NoNewline + # Same reason as the stage above: this one also expects 20, so it has to start + # from a field where nothing matches the name list. The preceding installer run + # can leave its own uninstaller helper behind briefly. + Clear-InstallerFallbackProcesses -Context 'Before no-CLR portable reinstall' $env:COMPLUS_Version = 'v0.0.0-test-invalid-clr' Invoke-ProcessExpectFailure -FilePath $installer -Stage 'Portable reinstall without CLR' -ExpectedExitCode 20 -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir") Remove-Item Env:COMPLUS_Version -ErrorAction SilentlyContinue