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:
@@ -184,22 +184,39 @@ foreach ($app in $config.Applications) {
|
|||||||
Write-PreInstallLog " InstallArgs: $($app.InstallArgs)"
|
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 {
|
try {
|
||||||
# Start without -Wait so we can poll. UDC_Setup.exe in particular hangs forever
|
# We use [System.Diagnostics.Process]::Start() directly instead of Start-Process
|
||||||
# after install (it spawns UDC.exe and hides its main window without exiting),
|
# because PowerShell 5.1's Start-Process -PassThru has a bug where the returned
|
||||||
# so we can't rely on the process exiting on its own.
|
# 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") {
|
if ($app.Type -eq "MSI") {
|
||||||
$msiArgs = "/i `"$installerPath`""
|
$psi.FileName = "msiexec.exe"
|
||||||
|
$psi.Arguments = "/i `"$installerPath`""
|
||||||
if ($app.InstallArgs) {
|
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") {
|
elseif ($app.Type -eq "EXE") {
|
||||||
|
$psi.FileName = $installerPath
|
||||||
if ($app.InstallArgs) {
|
if ($app.InstallArgs) {
|
||||||
$proc = Start-Process -FilePath $installerPath -ArgumentList $app.InstallArgs -PassThru -NoNewWindow
|
$psi.Arguments = $app.InstallArgs
|
||||||
} else {
|
|
||||||
$proc = Start-Process -FilePath $installerPath -PassThru -NoNewWindow
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -208,6 +225,8 @@ foreach ($app in $config.Applications) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||||
|
|
||||||
# Poll for completion: process exit OR detection success (whichever happens first)
|
# Poll for completion: process exit OR detection success (whichever happens first)
|
||||||
$timeoutSec = 600 # 10 min hard cap per app
|
$timeoutSec = 600 # 10 min hard cap per app
|
||||||
$pollInterval = 5
|
$pollInterval = 5
|
||||||
@@ -256,16 +275,59 @@ foreach ($app in $config.Applications) {
|
|||||||
Write-PreInstallLog " SUCCESS (verified via detection during install)"
|
Write-PreInstallLog " SUCCESS (verified via detection during install)"
|
||||||
$installed++
|
$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 {
|
else {
|
||||||
Write-PreInstallLog " Exit code $($proc.ExitCode) - FAILED" "ERROR"
|
# Start-Process -PassThru returns a Process object whose ExitCode is null
|
||||||
$failed++
|
# 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 {
|
} catch {
|
||||||
Write-PreInstallLog " Install threw: $_" "ERROR"
|
Write-PreInstallLog " Install threw: $_" "ERROR"
|
||||||
|
|||||||
Reference in New Issue
Block a user