Runs builds in pwsh process for reliable cancel

Improves UI responsiveness and interactive behavior by running build/cleanup in a separate PowerShell process instead of background jobs.

Fixes cancellation reliability by terminating the full process tree (including child tools) and using process exit codes for success/failure reporting.

Reduces noisy output by suppressing type-add return values and standardizes cleanup argument passing to avoid switch/boolean binding issues.
This commit is contained in:
rbalsleyMSFT
2026-01-29 22:21:15 -08:00
parent b2a7ef5f41
commit 1feed40962
2 changed files with 158 additions and 148 deletions
+1 -1
View File
@@ -584,7 +584,7 @@ public static extern uint GetPrivateProfileSection(
uint nSize, uint nSize,
string lpFileName); string lpFileName);
'@ '@
Add-Type -MemberDefinition $definition -Namespace Win32 -Name Kernel32 -PassThru Add-Type -MemberDefinition $definition -Namespace Win32 -Name Kernel32 -PassThru | Out-Null
#Check if Hyper-V feature is installed (requires only checks the module) #Check if Hyper-V feature is installed (requires only checks the module)
$osInfo = Get-CimInstance -ClassName win32_OperatingSystem $osInfo = Get-CimInstance -ClassName win32_OperatingSystem
+156 -146
View File
@@ -45,6 +45,7 @@ $script:uiState = [PSCustomObject]@{
logData = $null; logData = $null;
logStreamReader = $null; logStreamReader = $null;
pollTimer = $null; pollTimer = $null;
currentBuildProcess = $null;
lastConfigFilePath = $null lastConfigFilePath = $null
}; };
Flags = @{ Flags = @{
@@ -148,7 +149,7 @@ $script:uiState.Controls.btnRun.Add_Click({
if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) { if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) {
$btnRun.IsEnabled = $false $btnRun.IsEnabled = $false
$script:uiState.Controls.txtStatus.Text = "Cancel requested. Stopping build..." $script:uiState.Controls.txtStatus.Text = "Cancel requested. Stopping build..."
WriteLog "Cancel requested by user. Stopping background build job." WriteLog "Cancel requested by user. Stopping background build process."
# Stop the timer # Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) { if ($null -ne $script:uiState.Data.pollTimer) {
@@ -163,92 +164,71 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.logStreamReader = $null $script:uiState.Data.logStreamReader = $null
} }
# Stop and remove the running build job # Stop the running build process
$jobToStop = $script:uiState.Data.currentBuildJob $processToStop = $script:uiState.Data.currentBuildProcess
$script:uiState.Data.currentBuildJob = $null $script:uiState.Data.currentBuildProcess = $null
if ($null -ne $jobToStop) {
try { if ($null -ne $processToStop) {
# Attempt graceful stop first # Recursively terminate the build process and any children (DISM, setup tools, etc.)
Stop-Job -Job $jobToStop -ErrorAction SilentlyContinue function Stop-ProcessTree {
Wait-Job -Job $jobToStop -Timeout 5 -ErrorAction SilentlyContinue | Out-Null param([int]$parentPid)
} $children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
catch { foreach ($child in $children) {
WriteLog "Stop-Job threw: $($_.Exception.Message)" Stop-ProcessTree -parentPid $child.ProcessId
}
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
} }
# If the job's hosting process is still alive, kill its process tree to stop child tools like DISM
try { try {
$jobProcId = $null Stop-ProcessTree -parentPid $processToStop.Id
if ($null -ne $jobToStop.ChildJobs -and $jobToStop.ChildJobs.Count -gt 0) { WriteLog "Background build process stopped (PID: $($processToStop.Id))."
$jobProcId = $jobToStop.ChildJobs[0].ProcessId }
} catch {
if ($jobProcId) { WriteLog "Error terminating build process tree: $($_.Exception.Message)"
# Recursively terminate the job process and any children }
function Stop-ProcessTree { }
param([int]$parentPid)
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue # Safety net: kill any active DISM capture still running
foreach ($child in $children) { try {
Stop-ProcessTree -parentPid $child.ProcessId $dismCaptures = Get-CimInstance Win32_Process -Filter "Name='DISM.EXE'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '/Capture-FFU' }
} foreach ($p in $dismCaptures) {
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {} try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
}
}
catch {
WriteLog "Error stopping DISM capture processes: $($_.Exception.Message)"
}
# Also stop Office ODT setup.exe if running (to avoid recreating files after cleanup)
try {
$officePathForKill = $null
# Prefer explicit UI path
$uiOfficePath = $script:uiState.Controls.txtOfficePath.Text
if (-not [string]::IsNullOrWhiteSpace($uiOfficePath)) {
$officePathForKill = $uiOfficePath
}
else {
# Fall back to the last config path only if known
$lastConfigPathLocal = $script:uiState.Data.lastConfigFilePath
if (-not [string]::IsNullOrWhiteSpace($lastConfigPathLocal)) {
$ffuDevRoot = Split-Path (Split-Path $lastConfigPathLocal -Parent) -Parent
if (-not [string]::IsNullOrWhiteSpace($ffuDevRoot)) {
$officePathForKill = Join-Path $ffuDevRoot 'Apps\Office'
} }
Stop-ProcessTree -parentPid $jobProcId
} }
} }
catch {
WriteLog "Error terminating job process tree: $($_.Exception.Message)"
}
# Safety net: kill any active DISM capture still running # Only proceed when a valid Office folder exists
try { if ($officePathForKill -and (Test-Path -LiteralPath $officePathForKill -PathType Container)) {
$dismCaptures = Get-CimInstance Win32_Process -Filter "Name='DISM.EXE'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '/Capture-FFU' } $setupProcs = Get-CimInstance Win32_Process -Filter "Name='setup.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -like "$officePathForKill*" }
foreach ($p in $dismCaptures) { foreach ($p in $setupProcs) {
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {} try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
} }
} }
catch { }
WriteLog "Error stopping DISM capture processes: $($_.Exception.Message)" catch {
} WriteLog "Error stopping Office setup.exe processes: $($_.Exception.Message)"
# Also stop Office ODT setup.exe if running (to avoid recreating files after cleanup)
try {
$officePathForKill = $null
# Prefer explicit UI path
$uiOfficePath = $script:uiState.Controls.txtOfficePath.Text
if (-not [string]::IsNullOrWhiteSpace($uiOfficePath)) {
$officePathForKill = $uiOfficePath
}
else {
# Fall back to the last config path only if known
$lastConfigPathLocal = $script:uiState.Data.lastConfigFilePath
if (-not [string]::IsNullOrWhiteSpace($lastConfigPathLocal)) {
$ffuDevRoot = Split-Path (Split-Path $lastConfigPathLocal -Parent) -Parent
if (-not [string]::IsNullOrWhiteSpace($ffuDevRoot)) {
$officePathForKill = Join-Path $ffuDevRoot 'Apps\Office'
}
}
}
# Only proceed when a valid Office folder exists
if ($officePathForKill -and (Test-Path -LiteralPath $officePathForKill -PathType Container)) {
$setupProcs = Get-CimInstance Win32_Process -Filter "Name='setup.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -like "$officePathForKill*" }
foreach ($p in $setupProcs) {
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
}
}
}
catch {
WriteLog "Error stopping Office setup.exe processes: $($_.Exception.Message)"
}
try {
Remove-Job -Job $jobToStop -Force -ErrorAction SilentlyContinue
WriteLog "Background build job stopped and removed."
}
catch {
WriteLog "Error removing background build job: $($_.Exception.Message)"
}
} }
# Start cleanup using the same BuildFFUVM.ps1 via -Cleanup short-circuit # Start cleanup using the same BuildFFUVM.ps1 via -Cleanup short-circuit
@@ -289,13 +269,39 @@ $script:uiState.Controls.btnRun.Add_Click({
CleanupCurrentRunDownloads = $removeCurrentRunToo CleanupCurrentRunDownloads = $removeCurrentRunToo
} }
$cleanupScriptBlock = { # Start cleanup in a separate pwsh process so the UI stays responsive
param($buildParams, $PSScriptRoot) $pwshPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams if (-not (Test-Path -Path $pwshPath)) {
$pwshPath = 'pwsh'
} }
# Start cleanup job $cleanupScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'BuildFFUVM.ps1'
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $cleanupScriptBlock -ArgumentList @($cleanupParams, $PSScriptRoot)
# Build argument list for cleanup.
# -Cleanup is a [switch] in BuildFFUVM.ps1, so do not pass a value after it.
# Use -Param:$true/$false syntax for boolean parameters to avoid argument transformation errors.
$cleanupArgs = @(
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', $cleanupScriptPath,
'-ConfigFile', $cleanupParams.ConfigFile,
'-Cleanup',
"-RemoveApps:$($cleanupParams.RemoveApps)",
"-RemoveUpdates:$($cleanupParams.RemoveUpdates)",
"-CleanupDrivers:$($cleanupParams.CleanupDrivers)",
"-CleanupCurrentRunDownloads:$($cleanupParams.CleanupCurrentRunDownloads)"
)
$startCleanupParams = @{
FilePath = $pwshPath
ArgumentList = $cleanupArgs
PassThru = $true
}
if ($Host.Name -eq 'ConsoleHost') {
$startCleanupParams['NoNewWindow'] = $true
}
$script:uiState.Data.currentBuildProcess = Start-Process @startCleanupParams
# Wait for log file to appear (or open immediately if it exists) # Wait for log file to appear (or open immediately if it exists)
$logWaitTimeout = 60 $logWaitTimeout = 60
@@ -315,14 +321,14 @@ $script:uiState.Controls.btnRun.Add_Click({
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update during cleanup." WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update during cleanup."
} }
# Create a timer to poll the cleanup job # Create a timer to poll the cleanup process
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer $script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1) $script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
$script:uiState.Flags.isCleanupRunning = $true $script:uiState.Flags.isCleanupRunning = $true
$script:uiState.Data.pollTimer.Add_Tick({ $script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e) param($sender, $e)
$currentJob = $script:uiState.Data.currentBuildJob $currentProcess = $script:uiState.Data.currentBuildProcess
# Read new lines from log # Read new lines from log
if ($null -ne $script:uiState.Data.logStreamReader) { if ($null -ne $script:uiState.Data.logStreamReader) {
@@ -335,13 +341,13 @@ $script:uiState.Controls.btnRun.Add_Click({
} }
} }
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) { if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) { $sender.Stop() } if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null $script:uiState.Data.pollTimer = $null
return return
} }
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') { if ($currentProcess.HasExited) {
if ($null -ne $sender) { $sender.Stop() } if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null $script:uiState.Data.pollTimer = $null
@@ -364,10 +370,8 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed' $script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
$script:uiState.Controls.pbOverallProgress.Value = 0 $script:uiState.Controls.pbOverallProgress.Value = 0
# Receive and remove cleanup job # Clear cleanup process state
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null $script:uiState.Data.currentBuildProcess = $null
Remove-Job -Job $currentJob -Force
$script:uiState.Data.currentBuildJob = $null
# Reset flags and button # Reset flags and button
$script:uiState.Flags.isCleanupRunning = $false $script:uiState.Flags.isCleanupRunning = $false
@@ -425,33 +429,44 @@ $script:uiState.Controls.btnRun.Add_Click({
$txtStatus.Text = "Executing BuildFFUVM.ps1 in the background..." $txtStatus.Text = "Executing BuildFFUVM.ps1 in the background..."
WriteLog "Executing BuildFFUVM.ps1 in the background..." WriteLog "Executing BuildFFUVM.ps1 in the background..."
# Prepare parameters for splatting # Start BuildFFUVM.ps1 in a separate pwsh process.
$buildParams = @{ # This keeps the UI responsive and restores console interaction (Write-Host / Read-Host) when available.
ConfigFile = $configFilePath $pwshPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'
if (-not (Test-Path -Path $pwshPath)) {
$pwshPath = 'pwsh'
} }
$buildScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'BuildFFUVM.ps1'
$pwshArgs = @(
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', $buildScriptPath,
'-ConfigFile', $configFilePath
)
if ($config.Verbose) { if ($config.Verbose) {
$buildParams['Verbose'] = $true $pwshArgs += '-Verbose'
} }
# Define the script block to run in the background job # Delete the old log file before starting the build process to ensure we don't read stale content.
$scriptBlock = {
param($buildParams, $PSScriptRoot)
# This script runs in a new process. BuildFFUVM.ps1 is expected to handle its own module imports.
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams
}
# Delete the old log file before starting the build job to ensure we don't read stale content.
$mainLogPath = Join-Path $config.FFUDevelopmentPath "FFUDevelopment.log" $mainLogPath = Join-Path $config.FFUDevelopmentPath "FFUDevelopment.log"
if (Test-Path $mainLogPath) { if (Test-Path $mainLogPath) {
WriteLog "Removing old FFUDevelopment.log file." WriteLog "Removing old FFUDevelopment.log file."
Remove-Item -Path $mainLogPath -Force Remove-Item -Path $mainLogPath -Force
} }
# Start the job and store it in the shared state object $startBuildParams = @{
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $scriptBlock -ArgumentList @($buildParams, $PSScriptRoot) FilePath = $pwshPath
ArgumentList = $pwshArgs
PassThru = $true
}
if ($Host.Name -eq 'ConsoleHost') {
$startBuildParams['NoNewWindow'] = $true
}
# Wait for the new log file to be created by the background job. # Start the build process and store it in the shared state object
$script:uiState.Data.currentBuildProcess = Start-Process @startBuildParams
# Wait for the new log file to be created by the background process.
$logWaitTimeout = 15 # seconds $logWaitTimeout = 15 # seconds
$watch = [System.Diagnostics.Stopwatch]::StartNew() $watch = [System.Diagnostics.Stopwatch]::StartNew()
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) { while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
@@ -476,7 +491,7 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.pollTimer.Add_Tick({ $script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e) param($sender, $e)
# This scriptblock runs on the UI thread, so it can safely access script-scoped variables # This scriptblock runs on the UI thread, so it can safely access script-scoped variables
$currentJob = $script:uiState.Data.currentBuildJob $currentProcess = $script:uiState.Data.currentBuildProcess
# Read from log stream # Read from log stream
if ($null -ne $script:uiState.Data.logStreamReader) { if ($null -ne $script:uiState.Data.logStreamReader) {
@@ -500,8 +515,8 @@ $script:uiState.Controls.btnRun.Add_Click({
} }
} }
# If job is somehow null or the timer has been nulled out, stop the timer # If process is somehow null or the timer has been nulled out, stop the timer
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) { if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) { if ($null -ne $sender) {
$sender.Stop() $sender.Stop()
} }
@@ -509,8 +524,8 @@ $script:uiState.Controls.btnRun.Add_Click({
return return
} }
# Check if the job has reached a terminal state # Check if the build process has exited
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') { if ($currentProcess.HasExited) {
# Stop the timer, we're done polling # Stop the timer, we're done polling
if ($null -ne $sender) { if ($null -ne $sender) {
$sender.Stop() $sender.Stop()
@@ -546,42 +561,26 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.logStreamReader = $null $script:uiState.Data.logStreamReader = $null
} }
# Determine final status based on job result and whether cleanup was running (should be false here) $exitCode = $currentProcess.ExitCode
# Determine final status based on process exit code
$finalStatusText = "FFU build completed successfully." $finalStatusText = "FFU build completed successfully."
if ($currentJob.State -eq 'Failed') { if ($exitCode -ne 0) {
$reason = $null
Receive-Job -Job $currentJob -Keep -ErrorVariable jobErrors -ErrorAction SilentlyContinue | Out-Null
if ($null -ne $jobErrors -and $jobErrors.Count -gt 0) {
$reason = ($jobErrors | Select-Object -Last 1).ToString()
}
if ([string]::IsNullOrWhiteSpace($reason) -and $currentJob.JobStateInfo.Reason) {
$reason = $currentJob.JobStateInfo.Reason.Message
}
if ([string]::IsNullOrWhiteSpace($reason)) {
$reason = "An unknown error occurred. The job failed without a specific reason."
}
$finalStatusText = "FFU build failed. Check FFUDevelopment.log for details." $finalStatusText = "FFU build failed. Check FFUDevelopment.log for details."
WriteLog "BuildFFUVM.ps1 job failed. Reason: $reason" WriteLog "BuildFFUVM.ps1 process failed with exit code: $exitCode"
[System.Windows.MessageBox]::Show("The build process failed. Please check the $FFUDevelopmentPath\FFUDevelopment.log file for details.`n`nError: $reason", "Build Error", "OK", "Error") | Out-Null [System.Windows.MessageBox]::Show("The build process failed. Please check the $FFUDevelopmentPath\FFUDevelopment.log file for details.`n`nExit code: $exitCode", "Build Error", "OK", "Error") | Out-Null
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed' $script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
} }
else { else {
WriteLog "BuildFFUVM.ps1 job completed successfully." WriteLog "BuildFFUVM.ps1 process completed successfully."
$script:uiState.Controls.pbOverallProgress.Value = 100 $script:uiState.Controls.pbOverallProgress.Value = 100
} }
# Update UI elements # Update UI elements
$script:uiState.Controls.txtStatus.Text = $finalStatusText $script:uiState.Controls.txtStatus.Text = $finalStatusText
# Receive & remove job and clear state # Clear process state
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null $script:uiState.Data.currentBuildProcess = $null
Remove-Job -Job $currentJob -Force
$script:uiState.Data.currentBuildJob = $null
# Reset button and flags for next run # Reset button and flags for next run
$script:uiState.Flags.isBuilding = $false $script:uiState.Flags.isBuilding = $false
@@ -640,9 +639,9 @@ $window.Add_SourceInitialized({
# Register cleanup to reclaim memory and revert LongPathsEnabled setting when the UI window closes # Register cleanup to reclaim memory and revert LongPathsEnabled setting when the UI window closes
$window.Add_Closed({ $window.Add_Closed({
# Stop any running build job if the window is closed # Stop any running build process if the window is closed
if ($null -ne $script:uiState.Data.currentBuildJob) { if ($null -ne $script:uiState.Data.currentBuildProcess) {
WriteLog "UI closing, stopping background build job." WriteLog "UI closing, stopping background build process."
# Stop the timer # Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) { if ($null -ne $script:uiState.Data.pollTimer) {
@@ -657,17 +656,28 @@ $window.Add_Closed({
$script:uiState.Data.logStreamReader = $null $script:uiState.Data.logStreamReader = $null
} }
# Stop and remove the job $processToStop = $script:uiState.Data.currentBuildProcess
$jobToStop = $script:uiState.Data.currentBuildJob $script:uiState.Data.currentBuildProcess = $null
$script:uiState.Data.currentBuildJob = $null # Clear it from state first
try { try {
Stop-Job -Job $jobToStop # Terminate the build process and any children
Remove-Job -Job $jobToStop function Stop-ProcessTree {
WriteLog "Background job stopped and removed." param([int]$parentPid)
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
foreach ($child in $children) {
Stop-ProcessTree -parentPid $child.ProcessId
}
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
}
if ($null -ne $processToStop -and -not $processToStop.HasExited) {
Stop-ProcessTree -parentPid $processToStop.Id
}
WriteLog "Background process stopped."
} }
catch { catch {
WriteLog "Error stopping or removing background job: $($_.Exception.Message)" WriteLog "Error stopping background build process: $($_.Exception.Message)"
} }
} }