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,
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)
$osInfo = Get-CimInstance -ClassName win32_OperatingSystem
+112 -102
View File
@@ -45,6 +45,7 @@ $script:uiState = [PSCustomObject]@{
logData = $null;
logStreamReader = $null;
pollTimer = $null;
currentBuildProcess = $null;
lastConfigFilePath = $null
};
Flags = @{
@@ -148,7 +149,7 @@ $script:uiState.Controls.btnRun.Add_Click({
if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) {
$btnRun.IsEnabled = $false
$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
if ($null -ne $script:uiState.Data.pollTimer) {
@@ -163,27 +164,12 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.logStreamReader = $null
}
# Stop and remove the running build job
$jobToStop = $script:uiState.Data.currentBuildJob
$script:uiState.Data.currentBuildJob = $null
if ($null -ne $jobToStop) {
try {
# Attempt graceful stop first
Stop-Job -Job $jobToStop -ErrorAction SilentlyContinue
Wait-Job -Job $jobToStop -Timeout 5 -ErrorAction SilentlyContinue | Out-Null
}
catch {
WriteLog "Stop-Job threw: $($_.Exception.Message)"
}
# Stop the running build process
$processToStop = $script:uiState.Data.currentBuildProcess
$script:uiState.Data.currentBuildProcess = $null
# If the job's hosting process is still alive, kill its process tree to stop child tools like DISM
try {
$jobProcId = $null
if ($null -ne $jobToStop.ChildJobs -and $jobToStop.ChildJobs.Count -gt 0) {
$jobProcId = $jobToStop.ChildJobs[0].ProcessId
}
if ($jobProcId) {
# Recursively terminate the job process and any children
if ($null -ne $processToStop) {
# Recursively terminate the build process and any children (DISM, setup tools, etc.)
function Stop-ProcessTree {
param([int]$parentPid)
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
@@ -192,11 +178,14 @@ $script:uiState.Controls.btnRun.Add_Click({
}
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
}
Stop-ProcessTree -parentPid $jobProcId
}
try {
Stop-ProcessTree -parentPid $processToStop.Id
WriteLog "Background build process stopped (PID: $($processToStop.Id))."
}
catch {
WriteLog "Error terminating job process tree: $($_.Exception.Message)"
WriteLog "Error terminating build process tree: $($_.Exception.Message)"
}
}
# Safety net: kill any active DISM capture still running
@@ -242,15 +231,6 @@ $script:uiState.Controls.btnRun.Add_Click({
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
$lastConfigPath = $script:uiState.Data.lastConfigFilePath
if ([string]::IsNullOrWhiteSpace($lastConfigPath)) {
@@ -289,13 +269,39 @@ $script:uiState.Controls.btnRun.Add_Click({
CleanupCurrentRunDownloads = $removeCurrentRunToo
}
$cleanupScriptBlock = {
param($buildParams, $PSScriptRoot)
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams
# Start cleanup in a separate pwsh process so the UI stays responsive
$pwshPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'
if (-not (Test-Path -Path $pwshPath)) {
$pwshPath = 'pwsh'
}
# Start cleanup job
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $cleanupScriptBlock -ArgumentList @($cleanupParams, $PSScriptRoot)
$cleanupScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'BuildFFUVM.ps1'
# 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)
$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."
}
# 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.Interval = [TimeSpan]::FromSeconds(1)
$script:uiState.Flags.isCleanupRunning = $true
$script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e)
$currentJob = $script:uiState.Data.currentBuildJob
$currentProcess = $script:uiState.Data.currentBuildProcess
# Read new lines from log
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() }
$script:uiState.Data.pollTimer = $null
return
}
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') {
if ($currentProcess.HasExited) {
if ($null -ne $sender) { $sender.Stop() }
$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.Value = 0
# Receive and remove cleanup job
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null
Remove-Job -Job $currentJob -Force
$script:uiState.Data.currentBuildJob = $null
# Clear cleanup process state
$script:uiState.Data.currentBuildProcess = $null
# Reset flags and button
$script:uiState.Flags.isCleanupRunning = $false
@@ -425,33 +429,44 @@ $script:uiState.Controls.btnRun.Add_Click({
$txtStatus.Text = "Executing BuildFFUVM.ps1 in the background..."
WriteLog "Executing BuildFFUVM.ps1 in the background..."
# Prepare parameters for splatting
$buildParams = @{
ConfigFile = $configFilePath
# Start BuildFFUVM.ps1 in a separate pwsh process.
# This keeps the UI responsive and restores console interaction (Write-Host / Read-Host) when available.
$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) {
$buildParams['Verbose'] = $true
$pwshArgs += '-Verbose'
}
# Define the script block to run in the background job
$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.
# Delete the old log file before starting the build process to ensure we don't read stale content.
$mainLogPath = Join-Path $config.FFUDevelopmentPath "FFUDevelopment.log"
if (Test-Path $mainLogPath) {
WriteLog "Removing old FFUDevelopment.log file."
Remove-Item -Path $mainLogPath -Force
}
# Start the job and store it in the shared state object
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $scriptBlock -ArgumentList @($buildParams, $PSScriptRoot)
$startBuildParams = @{
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
$watch = [System.Diagnostics.Stopwatch]::StartNew()
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({
param($sender, $e)
# 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
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 ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) {
# If process is somehow null or the timer has been nulled out, stop the timer
if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) {
$sender.Stop()
}
@@ -509,8 +524,8 @@ $script:uiState.Controls.btnRun.Add_Click({
return
}
# Check if the job has reached a terminal state
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') {
# Check if the build process has exited
if ($currentProcess.HasExited) {
# Stop the timer, we're done polling
if ($null -ne $sender) {
$sender.Stop()
@@ -546,42 +561,26 @@ $script:uiState.Controls.btnRun.Add_Click({
$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."
if ($currentJob.State -eq 'Failed') {
$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."
}
if ($exitCode -ne 0) {
$finalStatusText = "FFU build failed. Check FFUDevelopment.log for details."
WriteLog "BuildFFUVM.ps1 job failed. Reason: $reason"
[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
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`nExit code: $exitCode", "Build Error", "OK", "Error") | Out-Null
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
}
else {
WriteLog "BuildFFUVM.ps1 job completed successfully."
WriteLog "BuildFFUVM.ps1 process completed successfully."
$script:uiState.Controls.pbOverallProgress.Value = 100
}
# Update UI elements
$script:uiState.Controls.txtStatus.Text = $finalStatusText
# Receive & remove job and clear state
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null
Remove-Job -Job $currentJob -Force
$script:uiState.Data.currentBuildJob = $null
# Clear process state
$script:uiState.Data.currentBuildProcess = $null
# Reset button and flags for next run
$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
$window.Add_Closed({
# Stop any running build job if the window is closed
if ($null -ne $script:uiState.Data.currentBuildJob) {
WriteLog "UI closing, stopping background build job."
# Stop any running build process if the window is closed
if ($null -ne $script:uiState.Data.currentBuildProcess) {
WriteLog "UI closing, stopping background build process."
# Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) {
@@ -657,17 +656,28 @@ $window.Add_Closed({
$script:uiState.Data.logStreamReader = $null
}
# Stop and remove the job
$jobToStop = $script:uiState.Data.currentBuildJob
$script:uiState.Data.currentBuildJob = $null # Clear it from state first
$processToStop = $script:uiState.Data.currentBuildProcess
$script:uiState.Data.currentBuildProcess = $null
try {
Stop-Job -Job $jobToStop
Remove-Job -Job $jobToStop
WriteLog "Background job stopped and removed."
# Terminate the build process and any children
function Stop-ProcessTree {
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 {
WriteLog "Error stopping or removing background job: $($_.Exception.Message)"
WriteLog "Error stopping background build process: $($_.Exception.Message)"
}
}