Skip to content

Commit 730e21f

Browse files
committed
fix(test): stop attributing stray processes to the no-CLR installer stages
`Verify Windows installer execution` failed twice in a row on the v0.5.0 tag, both times at the same assertion: Elevated default-mode reinstall without CLR expected process exit code 20, received 22. 20 and 22 are different answers to "why did setup stop". 20 is legacy recovery refusing to continue; 22 is "a matching process is running". The stage breaks the CLR on purpose so the installer cannot run PowerShell, which is exactly when CcHahaFindInstallProcess degrades from resolving paths to matching bare image names -- `Claude Code Haha.exe`, the three `claude-sidecar*` names, `OpenConsole.exe`, `winpty-agent.exe`, `rg.exe`. Any process on the runner carrying one of those names answers for the stage, whoever started it. The stage before it expects 22, so a stray match there is indistinguishable from a pass; the stage after it expects 20, so the same stray match is a failure. That asymmetry is why this reads as "one flaky assertion" rather than "the whole no-CLR group is unguarded". This does not fix the installer, because the installer is not wrong: refusing to delete user data when it cannot confirm what is running is the intended fail-closed behaviour, and neither installer.nsh nor this script changed between v0.4.11 (green) and v0.5.0 (red). What changed is what was running on the runner. So the script now controls that instead of assuming it: - Both stages that expect 20 first clear any process matching the same name list. `WaitForExit` only covers the PIDs this script started; the fallback matches names, so it also sees leftovers from earlier steps of this job -- the compiled-sidecar smoke starts 20 sidecars -- and any child a probe spawned. - Clearing warns instead of throwing when something survives. A survivor is runner-owned and out of reach, and failing there would replace the stage's own failure with a less informative one. - A mismatched exit code now prints every matching process with PID and path, and a baseline is printed before the first install. Between those two, a future failure says whether the runner was dirty or the installer regressed, which this run had no way to answer. The name list is duplicated from installer.nsh by necessity -- NSIS compile-time state is not readable from PowerShell -- and is commented on both sides to be kept in sync. Not verified locally: this needs Windows and an ephemeral runner (the script refuses to run unless CI=true, since it mutates installer registry state). Reviewed for Windows PowerShell 5.1, which is what the workflow invokes: no pwsh-only syntax, and the file is kept pure ASCII as it was, so 5.1 cannot mis-decode it.
1 parent c64f699 commit 730e21f

1 file changed

Lines changed: 108 additions & 0 deletions

File tree

‎desktop/scripts/windows-installer-smoke.ps1‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ function Invoke-ProcessExpectFailure {
5555
}
5656
if ($PSBoundParameters.ContainsKey('ExpectedExitCode')) {
5757
if ($process.ExitCode -ne $ExpectedExitCode) {
58+
# 22 means "a matching process is running" and 20 means "legacy recovery
59+
# refused to continue". Getting 22 where 20 was expected says the
60+
# installer stopped before it ever reached recovery, so the process list
61+
# is the evidence that separates a real regression from a dirty runner.
62+
Write-InstallerFallbackProcessDiagnostic -Context "$Stage (expected $ExpectedExitCode, got $($process.ExitCode))"
5863
throw "$Stage expected process exit code $ExpectedExitCode, received $($process.ExitCode)."
5964
}
6065
} elseif ($process.ExitCode -eq 0) {
@@ -100,6 +105,93 @@ function Test-IsProcessElevated {
100105
}
101106
}
102107

108+
# The exact image names installer.nsh falls back to when PowerShell is
109+
# unavailable (see CcHahaFindInstallProcess's findstr list). Without a CLR the
110+
# installer cannot resolve paths, so it matches on these names alone and any
111+
# process carrying one -- whoever started it -- reads as "the app is still
112+
# running". Keep this in sync with installer.nsh.
113+
$installerFallbackImageNames = @(
114+
'Claude Code Haha.exe',
115+
'claude-sidecar-x86_64-pc-windows-msvc.exe',
116+
'claude-sidecar-aarch64-pc-windows-msvc.exe',
117+
'claude-sidecar.exe',
118+
'OpenConsole.exe',
119+
'winpty-agent.exe',
120+
'rg.exe'
121+
)
122+
123+
function Get-InstallerFallbackProcesses {
124+
# Not $matches: that is an automatic variable holding -match results.
125+
$running = @()
126+
foreach ($imageName in $installerFallbackImageNames) {
127+
$processName = [IO.Path]::GetFileNameWithoutExtension($imageName)
128+
foreach ($process in @(Get-Process -Name $processName -ErrorAction SilentlyContinue)) {
129+
$running += $process
130+
}
131+
}
132+
return $running
133+
}
134+
135+
# The no-CLR stages assert an exit code that says *why* the installer stopped,
136+
# so a stray same-named process anywhere on the runner silently rewrites the
137+
# answer. Print what the fallback would have seen; a stage that expected 22 and
138+
# got 22 for the wrong reason looks identical to a pass without this.
139+
function Write-InstallerFallbackProcessDiagnostic {
140+
param([Parameter(Mandatory = $true)][string]$Context)
141+
142+
[Console]::Out.WriteLine("--- $Context : processes matching the installer's no-CLR image-name fallback ---")
143+
$running = @(Get-InstallerFallbackProcesses)
144+
if ($running.Count -eq 0) {
145+
[Console]::Out.WriteLine(' (none)')
146+
} else {
147+
foreach ($process in $running) {
148+
$processPath = '<path unavailable>'
149+
try {
150+
if ($process.Path) { $processPath = $process.Path }
151+
} catch {
152+
# A process owned by another user denies path access; the name is the
153+
# part that matters here, since the name is all the fallback matches on.
154+
}
155+
[Console]::Out.WriteLine(" PID=$($process.Id); Name=$($process.ProcessName); Path=$processPath")
156+
}
157+
}
158+
[Console]::Out.WriteLine('--- end ---')
159+
}
160+
161+
# Earlier steps in this same CI job start sidecars and helpers, and this script
162+
# deliberately leaves probe processes running for the stages that assert on
163+
# them. Anything still alive when a later stage runs would be attributed to that
164+
# stage instead. Settle the field before a stage whose expected exit code
165+
# depends on nothing matching.
166+
function Clear-InstallerFallbackProcesses {
167+
param(
168+
[Parameter(Mandatory = $true)][string]$Context,
169+
[int]$TimeoutSeconds = 30
170+
)
171+
172+
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
173+
while ($true) {
174+
$running = @(Get-InstallerFallbackProcesses)
175+
if ($running.Count -eq 0) { return }
176+
177+
if ((Get-Date) -ge $deadline) {
178+
# Deliberately not a throw. Anything surviving this is outside the
179+
# script's control (a runner-owned process of the same name), and failing
180+
# here would replace the stage's own failure -- which now prints the same
181+
# process list -- with a less informative one. Report and let the stage
182+
# speak for itself.
183+
[Console]::Out.WriteLine("WARNING: $Context could not clear $($running.Count) matching process(es) within ${TimeoutSeconds}s.")
184+
Write-InstallerFallbackProcessDiagnostic -Context "$Context (still present after ${TimeoutSeconds}s)"
185+
return
186+
}
187+
188+
foreach ($process in $running) {
189+
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
190+
}
191+
Start-Sleep -Milliseconds 250
192+
}
193+
}
194+
103195
function Invoke-ProcessHelperExpectExit {
104196
param(
105197
[Parameter(Mandatory = $true)][string]$Stage,
@@ -160,6 +252,11 @@ try {
160252
Remove-Item Env:CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue
161253
Remove-Item Env:CC_HAHA_APP_PORTABLE_DIR -ErrorAction SilentlyContinue
162254

255+
# Baseline before anything here starts a process. Whatever this prints was put
256+
# there by earlier steps of the job or by the runner image, and it is exactly
257+
# what the no-CLR stages would misattribute to themselves.
258+
Write-InstallerFallbackProcessDiagnostic -Context 'Runner baseline before fresh install'
259+
163260
Invoke-CheckedProcess -FilePath $installer -Stage 'Fresh install' -Arguments @('/S', '/currentuser', "/D=$installDir")
164261
if (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) {
165262
throw "Fresh install did not create the application executable: $appExe"
@@ -218,6 +315,13 @@ try {
218315
$bundledHelperProcess.Dispose()
219316
$bundledHelperProcess = $null
220317

318+
# WaitForExit covers the PID this script started; the fallback matches on the
319+
# image name, so it also sees anything left over from earlier steps of this
320+
# job (the compiled-sidecar smoke starts 20 sidecars) and any child the probe
321+
# spawned. The next two stages expect 20 -- "recovery refused" -- which they can
322+
# only reach if nothing matches the name list first.
323+
Clear-InstallerFallbackProcesses -Context 'Before no-CLR default-mode reinstall'
324+
221325
$env:COMPLUS_Version = 'v0.0.0-test-invalid-clr'
222326
if (Test-IsProcessElevated) {
223327
Invoke-ProcessExpectFailure -FilePath $installer -Stage 'Elevated default-mode reinstall without CLR' -ExpectedExitCode 20 -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir")
@@ -233,6 +337,10 @@ try {
233337
$legacySentinel = Join-Path $legacyDir 'settings.json'
234338
New-Item -ItemType Directory -Path $legacyDir -Force | Out-Null
235339
Set-Content -LiteralPath $legacySentinel -Value 'must-survive-failed-upgrade' -NoNewline
340+
# Same reason as the stage above: this one also expects 20, so it has to start
341+
# from a field where nothing matches the name list. The preceding installer run
342+
# can leave its own uninstaller helper behind briefly.
343+
Clear-InstallerFallbackProcesses -Context 'Before no-CLR portable reinstall'
236344
$env:COMPLUS_Version = 'v0.0.0-test-invalid-clr'
237345
Invoke-ProcessExpectFailure -FilePath $installer -Stage 'Portable reinstall without CLR' -ExpectedExitCode 20 -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir")
238346
Remove-Item Env:COMPLUS_Version -ErrorAction SilentlyContinue

0 commit comments

Comments
 (0)