PreInstall runner: capture real exit codes, surface MSI errors

Three observability fixes that made the VC++ MSI failures actually
debuggable instead of showing "Exit code  - FAILED" with an empty
value for every install:

1. Switch from Start-Process -PassThru (without -Wait) to
   [System.Diagnostics.Process]::Start() with a ProcessStartInfo.
   PowerShell 5.1 has a known bug where Start-Process disposes the
   Process object's OS handle when control returns to the script,
   so $proc.ExitCode reads as $null even after WaitForExit() - which
   was causing every MSI install to be reported as failed regardless
   of the actual result.

2. Pass /L*v <log> to msiexec on every MSI install so we get a full
   verbose log per app at C:\Logs\PreInstall\msi-<safename>.log.

3. On install failure, scan the verbose log for *meaningful* lines
   (Note: 1: <code>, "return value 3", custom action errors, "Failed
   to", "Installation failed", common 2xxx error codes) instead of
   tailing the last 25 lines, which is rollback/cleanup noise. This
   surfaces the actual root-cause line directly in the runner log so
   you don't have to dig through C:\Logs to diagnose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-04-09 10:08:42 -04:00
parent 61e0f3a033
commit 564f14ffcf

View File

@@ -184,22 +184,39 @@ foreach ($app in $config.Applications) {
Write-PreInstallLog " InstallArgs: $($app.InstallArgs)"
}
# Per-app verbose msiexec log path - written by /L*v on MSI installs and tailed
# into the runner log on failure so we don't need to dig through C:\Logs manually.
$safeName = $app.Name -replace '[^a-zA-Z0-9]','_'
$msiLog = Join-Path $logDir "msi-$safeName.log"
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
try {
# Start without -Wait so we can poll. UDC_Setup.exe in particular hangs forever
# after install (it spawns UDC.exe and hides its main window without exiting),
# so we can't rely on the process exiting on its own.
# We use [System.Diagnostics.Process]::Start() directly instead of Start-Process
# because PowerShell 5.1's Start-Process -PassThru has a bug where the returned
# Process object's OS handle is disposed when control returns to the script,
# causing ExitCode to always read as $null even after WaitForExit() succeeds.
# Calling Process.Start() directly returns a Process object with a live handle
# that exposes ExitCode correctly. We poll HasExited so UDC_Setup.exe (which
# hangs forever after spawning a hidden WPF window) can be killed via the
# detection-during-install path.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
if ($app.Type -eq "MSI") {
$msiArgs = "/i `"$installerPath`""
$psi.FileName = "msiexec.exe"
$psi.Arguments = "/i `"$installerPath`""
if ($app.InstallArgs) {
$msiArgs += " " + $app.InstallArgs
$psi.Arguments += " " + $app.InstallArgs
}
$proc = Start-Process -FilePath "msiexec.exe" -ArgumentList $msiArgs -PassThru -NoNewWindow
$psi.Arguments += " /L*v `"$msiLog`""
Write-PreInstallLog " msiexec verbose log: $msiLog"
}
elseif ($app.Type -eq "EXE") {
$psi.FileName = $installerPath
if ($app.InstallArgs) {
$proc = Start-Process -FilePath $installerPath -ArgumentList $app.InstallArgs -PassThru -NoNewWindow
} else {
$proc = Start-Process -FilePath $installerPath -PassThru -NoNewWindow
$psi.Arguments = $app.InstallArgs
}
}
else {
@@ -208,6 +225,8 @@ foreach ($app in $config.Applications) {
continue
}
$proc = [System.Diagnostics.Process]::Start($psi)
# Poll for completion: process exit OR detection success (whichever happens first)
$timeoutSec = 600 # 10 min hard cap per app
$pollInterval = 5
@@ -256,16 +275,59 @@ foreach ($app in $config.Applications) {
Write-PreInstallLog " SUCCESS (verified via detection during install)"
$installed++
}
elseif ($proc.ExitCode -eq 0 -or $proc.ExitCode -eq 3010) {
Write-PreInstallLog " Exit code $($proc.ExitCode) after $elapsed s - SUCCESS"
if ($proc.ExitCode -eq 3010) {
Write-PreInstallLog " (Reboot required for $($app.Name))"
}
$installed++
}
else {
Write-PreInstallLog " Exit code $($proc.ExitCode) - FAILED" "ERROR"
$failed++
# Start-Process -PassThru returns a Process object whose ExitCode is null
# until WaitForExit() is called - even if HasExited is already true. Without
# this, every install that goes through the exit-code branch gets reported
# as "Exit code - FAILED" (empty) regardless of the real result.
try { $proc.WaitForExit() } catch { }
$exitCode = $proc.ExitCode
if ($exitCode -eq 0 -or $exitCode -eq 3010) {
Write-PreInstallLog " Exit code $exitCode after $elapsed s - SUCCESS"
if ($exitCode -eq 3010) {
Write-PreInstallLog " (Reboot required for $($app.Name))"
}
$installed++
}
else {
Write-PreInstallLog " Exit code $exitCode - FAILED" "ERROR"
# Surface the *meaningful* lines from the verbose msiexec log (not just
# the tail, which is rollback/cleanup noise). The actual root-cause lines
# are: MSI error notes (Note: 1: <code>), failed action returns (return
# value 3), errors logged by custom actions, and any line containing the
# word "Error". Grep these out so the runner log shows the smoking gun.
if ($app.Type -eq "MSI" -and (Test-Path $msiLog)) {
Write-PreInstallLog " --- meaningful lines from $msiLog ---"
$patterns = @(
'Note: 1: ',
'return value 3',
'Error \d+\.',
'CustomAction .* returned actual error',
'Failed to ',
'Installation failed',
'Calling custom action',
'1: 2262', # Database not found
'1: 2203', # Cannot open database
'1: 2330' # Insufficient permissions
)
$regex = ($patterns -join '|')
$matches = Select-String -Path $msiLog -Pattern $regex -ErrorAction SilentlyContinue |
Select-Object -First 30
if ($matches) {
foreach ($m in $matches) {
Write-PreInstallLog " $($m.Line.Trim())"
}
} else {
Write-PreInstallLog " (no error patterns matched - falling back to last 25 lines)"
Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object {
Write-PreInstallLog " $_"
}
}
Write-PreInstallLog " --- end MSI log scan ---"
}
$failed++
}
}
} catch {
Write-PreInstallLog " Install threw: $_" "ERROR"