From 59714f81e6cabb7fe72a85b52ac69923124383c5 Mon Sep 17 00:00:00 2001 From: Chris Titus Date: Wed, 1 Jul 2026 22:24:01 -0500 Subject: [PATCH] Defer GUI runspace pool startup --- .../private/Close-WinUtilRunspacePool.ps1 | 18 +++++ .../Initialize-WinUtilRunspacePool.ps1 | 39 +++++++++++ functions/public/Invoke-WPFRunspace.ps1 | 2 + pester/performance.Tests.ps1 | 3 +- pester/runspace-lifecycle.Tests.ps1 | 70 +++++++++++++++++++ pester/runspace.Tests.ps1 | 4 ++ pester/sanity.Tests.ps1 | 4 ++ pester/xaml.Tests.ps1 | 1 + scripts/main.ps1 | 51 +++----------- speed-todo.md | 10 +-- 10 files changed, 154 insertions(+), 48 deletions(-) create mode 100644 functions/private/Close-WinUtilRunspacePool.ps1 create mode 100644 functions/private/Initialize-WinUtilRunspacePool.ps1 create mode 100644 pester/runspace-lifecycle.Tests.ps1 diff --git a/functions/private/Close-WinUtilRunspacePool.ps1 b/functions/private/Close-WinUtilRunspacePool.ps1 new file mode 100644 index 00000000..22361dc2 --- /dev/null +++ b/functions/private/Close-WinUtilRunspacePool.ps1 @@ -0,0 +1,18 @@ +function Close-WinUtilRunspacePool { + if ($null -eq $sync -or -not $sync.ContainsKey("runspace") -or $null -eq $sync.runspace) { + return + } + + try { + if ($sync.runspace.RunspacePoolStateInfo.State -notin @( + [System.Management.Automation.Runspaces.RunspacePoolState]::Closed, + [System.Management.Automation.Runspaces.RunspacePoolState]::Closing, + [System.Management.Automation.Runspaces.RunspacePoolState]::Broken + )) { + $sync.runspace.Close() + } + } finally { + $sync.runspace.Dispose() + $sync.Remove("runspace") + } +} diff --git a/functions/private/Initialize-WinUtilRunspacePool.ps1 b/functions/private/Initialize-WinUtilRunspacePool.ps1 new file mode 100644 index 00000000..8cf56c90 --- /dev/null +++ b/functions/private/Initialize-WinUtilRunspacePool.ps1 @@ -0,0 +1,39 @@ +function Initialize-WinUtilRunspacePool { + if ($sync.runspace -and $sync.runspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) { + return $sync.runspace + } + + if ($sync.runspace) { + Close-WinUtilRunspacePool + } + + # Set the maximum number of threads for the RunspacePool to the number of threads on the machine. + $maxthreads = [Math]::Max([int]$env:NUMBER_OF_PROCESSORS, 1) + + # Create a new session state for parsing variables into our runspace. + $hashVars = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'sync', $sync, $null + $offlineVar = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'PARAM_OFFLINE', $PARAM_OFFLINE, $null + $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + + $initialSessionState.Variables.Add($hashVars) + $initialSessionState.Variables.Add($offlineVar) + + # Get every WinUtil/WPF function and add it to the session state. + $functions = Get-ChildItem function:\ | Where-Object { $_.Name -imatch 'winutil|WPF' } + foreach ($function in $functions) { + $functionDefinition = Get-Content function:\$($function.Name) + $functionEntry = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, $functionDefinition + $initialSessionState.Commands.Add($functionEntry) + } + + $sync.runspace = [runspacefactory]::CreateRunspacePool( + 1, # Minimum thread count + $maxthreads, # Maximum thread count + $initialSessionState, # Initial session state + $Host # Machine to create runspaces on + ) + + $sync.runspace.Open() + Write-WinUtilPerformanceCheckpoint -Name "Runspace pool initialized" + return $sync.runspace +} diff --git a/functions/public/Invoke-WPFRunspace.ps1 b/functions/public/Invoke-WPFRunspace.ps1 index cf9280d6..39c25d40 100644 --- a/functions/public/Invoke-WPFRunspace.ps1 +++ b/functions/public/Invoke-WPFRunspace.ps1 @@ -67,6 +67,8 @@ public static class WinUtilRunspaceCleanup "@ } + Initialize-WinUtilRunspacePool | Out-Null + # Create a PowerShell instance $powershell = [powershell]::Create() diff --git a/pester/performance.Tests.ps1 b/pester/performance.Tests.ps1 index fedbfa16..7fafd8bc 100644 --- a/pester/performance.Tests.ps1 +++ b/pester/performance.Tests.ps1 @@ -70,7 +70,8 @@ Describe "Startup performance checkpoints" { It "adds runtime checkpoints for startup hotspots" { $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw $lazyTabScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTabContent.ps1") -Raw - $startupText = "$mainScript`n$lazyTabScript" + $runspaceScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") -Raw + $startupText = "$mainScript`n$lazyTabScript`n$runspaceScript" foreach ($checkpoint in @( "Runspace pool initialized", diff --git a/pester/runspace-lifecycle.Tests.ps1 b/pester/runspace-lifecycle.Tests.ps1 new file mode 100644 index 00000000..00b79851 --- /dev/null +++ b/pester/runspace-lifecycle.Tests.ps1 @@ -0,0 +1,70 @@ +#=========================================================================== +# Tests - Runspace lifecycle +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1") + . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") +} + +Describe "Initialize-WinUtilRunspacePool" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{}) + $script:PARAM_OFFLINE = $false + + function Write-WinUtilPerformanceCheckpoint { param($Name) } + Mock Write-WinUtilPerformanceCheckpoint { } + } + + AfterEach { + Close-WinUtilRunspacePool + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name PARAM_OFFLINE -Scope Script -ErrorAction SilentlyContinue + } + + It "creates and reuses one open runspace pool" { + $firstPool = Initialize-WinUtilRunspacePool + $secondPool = Initialize-WinUtilRunspacePool + + $firstPool.RunspacePoolStateInfo.State | Should -Be ([System.Management.Automation.Runspaces.RunspacePoolState]::Opened) + [object]::ReferenceEquals($firstPool, $secondPool) | Should -BeTrue + Should -Invoke -CommandName Write-WinUtilPerformanceCheckpoint -Times 1 -Exactly -ParameterFilter { + $Name -eq "Runspace pool initialized" + } + } + + It "closes and removes the active runspace pool" { + $pool = Initialize-WinUtilRunspacePool + + Close-WinUtilRunspacePool + + $pool.RunspacePoolStateInfo.State | Should -Be ([System.Management.Automation.Runspaces.RunspacePoolState]::Closed) + $script:sync.ContainsKey("runspace") | Should -BeFalse + } +} + +Describe "Runspace startup wiring" { + It "does not create the GUI runspace pool before automation checks" { + $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + $beforePreset = $mainScript.Substring(0, $mainScript.IndexOf('if ($Preset)')) + + $beforePreset | Should -Not -Match '\[runspacefactory\]::CreateRunspacePool' + $beforePreset | Should -Not -Match '\$sync\.runspace\.Open\(\)' + } + + It "initializes runspaces synchronously for automation paths and after first render for GUI" { + $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + + $mainScript | Should -Match 'if \(\$Preset\) \{\s+Initialize-WinUtilRunspacePool' + $mainScript | Should -Match 'if \(\$Config\) \{\s+Initialize-WinUtilRunspacePool' + $mainScript | Should -Match 'Dispatcher\.BeginInvoke\(\[System\.Windows\.Threading\.DispatcherPriority\]::Background, \[action\]\{ Initialize-WinUtilRunspacePool' + $mainScript | Should -Match 'Close-WinUtilRunspacePool' + } + + It "creates runspaces on demand before queueing background work" { + $runspaceScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") -Raw + + $runspaceScript | Should -Match 'Initialize-WinUtilRunspacePool \| Out-Null' + } +} diff --git a/pester/runspace.Tests.ps1 b/pester/runspace.Tests.ps1 index 7acfe066..2fec90a5 100644 --- a/pester/runspace.Tests.ps1 +++ b/pester/runspace.Tests.ps1 @@ -4,6 +4,8 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1") + . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFFeatureInstall.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFAppxRemoval.ps1") @@ -18,6 +20,8 @@ BeforeAll { $initialSessionState.Variables.Add($syncVariable) $script:sync.runspace = [runspacefactory]::CreateRunspacePool(1, 2, $initialSessionState, $Host) $script:sync.runspace.Open() + + function Write-WinUtilPerformanceCheckpoint { param($Name) } } function script:Clear-WinUtilRunspaceTestContext { diff --git a/pester/sanity.Tests.ps1 b/pester/sanity.Tests.ps1 index 2799a2cb..ee9725c2 100644 --- a/pester/sanity.Tests.ps1 +++ b/pester/sanity.Tests.ps1 @@ -206,6 +206,10 @@ Describe "Compiled WinUtil sanity" { Describe "Runspace sanity" { BeforeAll { . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") + . (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1") + . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") + + function Write-WinUtilPerformanceCheckpoint { param($Name) } } It "returns a single async handle and runs a scriptblock with arguments in the shared runspace pool" { diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index bcc6ba19..b48b386f 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -295,6 +295,7 @@ Describe "XAML and sync wiring" { "keys", "ContainsKey", "GetEnumerator", + "Remove", "logorender", "checkmarkrender", "warningrender", diff --git a/scripts/main.ps1 b/scripts/main.ps1 index 4c63dc57..b881fb42 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -29,40 +29,6 @@ public enum PackageManagers } "@ -# SPDX-License-Identifier: MIT -# Set the maximum number of threads for the RunspacePool to the number of threads on the machine -$maxthreads = [int]$env:NUMBER_OF_PROCESSORS - -# Create a new session state for parsing variables into our runspace -$hashVars = New-object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'sync',$sync,$Null -$offlineVar = New-object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'PARAM_OFFLINE',$PARAM_OFFLINE,$Null -$InitialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() - -# Add the variable to the session state -$InitialSessionState.Variables.Add($hashVars) -$InitialSessionState.Variables.Add($offlineVar) - -# Get every private function and add them to the session state -$functions = Get-ChildItem function:\ | Where-Object { $_.Name -imatch 'winutil|WPF' } -foreach ($function in $functions) { - $functionDefinition = Get-Content function:\$($function.name) - $functionEntry = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $($function.name), $functionDefinition - - $initialSessionState.Commands.Add($functionEntry) -} - -# Create the runspace pool -$sync.runspace = [runspacefactory]::CreateRunspacePool( - 1, # Minimum thread count - $maxthreads, # Maximum thread count - $InitialSessionState, # Initial session state - $Host # Machine to create runspaces on -) - -# Open the RunspacePool instance -$sync.runspace.Open() -Write-WinUtilPerformanceCheckpoint -Name "Runspace pool initialized" - # Create classes for different exceptions class WingetFailedInstall : Exception { @@ -97,6 +63,8 @@ Set-Preferences Write-WinUtilPerformanceCheckpoint -Name "Preferences loaded" if ($Preset) { + Initialize-WinUtilRunspacePool | Out-Null + # Selects the tweaks from $Preset varible Update-WinUtilSelections -flatJson $sync.configs.preset.$Preset @@ -104,21 +72,21 @@ if ($Preset) { Invoke-WinUtilAutoRun # Cleanup and exit - $sync.runspace.Dispose() - $sync.runspace.Close() + Close-WinUtilRunspacePool [System.GC]::Collect() Stop-Transcript return } if ($Config) { + Initialize-WinUtilRunspacePool | Out-Null + Invoke-WPFImpex -type "import" -Config $Config Invoke-WinUtilAutoRun # Cleanup and exit - $sync.runspace.Dispose() - $sync.runspace.Close() + Close-WinUtilRunspacePool [System.GC]::Collect() Stop-Transcript return @@ -148,8 +116,7 @@ try { if (-NOT ($readerOperationSuccessful)) { Write-Host "Failed to parse xaml content using Windows.Markup.XamlReader's Load Method." -ForegroundColor Red Write-Host "Quitting WinUtil..." -ForegroundColor Red - $sync.runspace.Dispose() - $sync.runspace.Close() + Close-WinUtilRunspacePool [System.GC]::Collect() exit 1 } @@ -255,8 +222,7 @@ $sync["Form"].title = $sync["Form"].title + " " + $sync.version # Set the commands that will run when the form is closed $sync["Form"].Add_Closing({ Stop-WinUtilPerformanceTrace -Name "Window closing" - $sync.runspace.Dispose() - $sync.runspace.Close() + Close-WinUtilRunspacePool [System.GC]::Collect() }) @@ -373,6 +339,7 @@ $sync["Form"].Add_ContentRendered({ } $sync["Form"].Focus() + $sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilRunspacePool | Out-Null }) | Out-Null }) # The SearchBarTimer is used to delay the search operation until the user has stopped typing for a short period diff --git a/speed-todo.md b/speed-todo.md index 946423ae..9550328d 100644 --- a/speed-todo.md +++ b/speed-todo.md @@ -44,11 +44,11 @@ WinUtil currently does too much work before the first GUI paint. Work from the t ## P5 - Defer Runspace Pool Startup -- [ ] Move GUI runspace pool creation after first render when no automation mode is active. -- [ ] Keep synchronous runspace pool creation for `-Preset` and `-Config`. -- [ ] Prewarm the pool shortly after `ContentRendered` so later actions do not feel delayed. -- [ ] Ensure closing the form disposes the pool if it was created. -- [ ] Verify buttons that queue work create or reuse the pool safely. +- [x] Move GUI runspace pool creation after first render when no automation mode is active. +- [x] Keep synchronous runspace pool creation for `-Preset` and `-Config`. +- [x] Prewarm the pool shortly after `ContentRendered` so later actions do not feel delayed. +- [x] Ensure closing the form disposes the pool if it was created. +- [x] Verify buttons that queue work create or reuse the pool safely. ## P6 - Asset And Theme Cost