From 9973d7a1fe6400cb5aae1f04d1ad2de7584a0a35 Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:47:39 -0400 Subject: [PATCH 1/9] perf: load application favicons in background --- .../Close-WinUtilFaviconRunspacePool.ps1 | 39 ++++++ functions/private/Get-WinUtilFaviconUrl.ps1 | 12 ++ .../private/Initialize-InstallAppEntry.ps1 | 9 +- .../Initialize-WinUtilFaviconRunspacePool.ps1 | 23 ++++ .../private/Invoke-WinUtilFaviconFetch.ps1 | 127 ++++++++++++++++++ pester/favicon-loading.Tests.ps1 | 86 ++++++++++++ pester/xaml.Tests.ps1 | 3 + scripts/main.ps1 | 1 + 8 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 functions/private/Close-WinUtilFaviconRunspacePool.ps1 create mode 100644 functions/private/Get-WinUtilFaviconUrl.ps1 create mode 100644 functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 create mode 100644 functions/private/Invoke-WinUtilFaviconFetch.ps1 create mode 100644 pester/favicon-loading.Tests.ps1 diff --git a/functions/private/Close-WinUtilFaviconRunspacePool.ps1 b/functions/private/Close-WinUtilFaviconRunspacePool.ps1 new file mode 100644 index 0000000000..3fdb40d82f --- /dev/null +++ b/functions/private/Close-WinUtilFaviconRunspacePool.ps1 @@ -0,0 +1,39 @@ +function Close-WinUtilFaviconRunspacePool { + if ($null -eq $sync) { + return + } + + if ($sync.FaviconTimer) { + $sync.FaviconTimer.Stop() + $sync.Remove("FaviconTimer") + } + + if ($sync.FaviconOperations) { + foreach ($operation in @($sync.FaviconOperations.Values)) { + try { + $operation.PowerShell.Stop() + } catch { + } + try { + $operation.PowerShell.Dispose() + } catch { + } + } + $sync.FaviconOperations.Clear() + } + + if ($sync.FaviconRunspace) { + try { + if ($sync.FaviconRunspace.RunspacePoolStateInfo.State -notin @( + [System.Management.Automation.Runspaces.RunspacePoolState]::Closed, + [System.Management.Automation.Runspaces.RunspacePoolState]::Closing, + [System.Management.Automation.Runspaces.RunspacePoolState]::Broken + )) { + $sync.FaviconRunspace.Close() + } + } finally { + $sync.FaviconRunspace.Dispose() + $sync.Remove("FaviconRunspace") + } + } +} diff --git a/functions/private/Get-WinUtilFaviconUrl.ps1 b/functions/private/Get-WinUtilFaviconUrl.ps1 new file mode 100644 index 0000000000..4a2902ac0a --- /dev/null +++ b/functions/private/Get-WinUtilFaviconUrl.ps1 @@ -0,0 +1,12 @@ +function Get-WinUtilFaviconUrl { + param( + [Parameter(Mandatory = $true)] + [string]$Link + ) + + if ([string]::IsNullOrWhiteSpace($Link)) { + return $null + } + + return "https://www.google.com/s2/favicons?sz=64&domain_url=$([uri]::EscapeDataString($Link))" +} diff --git a/functions/private/Initialize-InstallAppEntry.ps1 b/functions/private/Initialize-InstallAppEntry.ps1 index 7fa501039d..711c6dbe8b 100644 --- a/functions/private/Initialize-InstallAppEntry.ps1 +++ b/functions/private/Initialize-InstallAppEntry.ps1 @@ -71,15 +71,15 @@ function Initialize-InstallAppEntry { $fallback = New-Object Windows.Controls.TextBlock $fallback.Text = $app.content.TrimStart(".").Substring(0, 1).ToUpper() $fallback.FontWeight = "Bold"; $fallback.HorizontalAlignment = "Center"; $fallback.VerticalAlignment = "Center" - if ($app.link) { $fallback.Visibility = "Collapsed" } $fallback.SetResourceReference([Windows.Controls.TextBlock]::FontSizeProperty, "AppEntryFontSize") $fallback.SetResourceReference([Windows.Controls.TextBlock]::ForegroundProperty, "ToggleButtonOnColor") [void]$icon.Children.Add($fallback) + $faviconUrl = $null if ($app.link) { $logo = New-Object Windows.Controls.Image $logo.Stretch = [Windows.Media.Stretch]::Uniform - $logo.Source = "https://www.google.com/s2/favicons?sz=64&domain_url=$([uri]::EscapeDataString($app.link))" - $logo.Add_ImageFailed({ $this.Visibility = "Collapsed"; $this.Parent.Children[0].Visibility = "Visible" }) + $logo.Visibility = [Windows.Visibility]::Collapsed + $faviconUrl = Get-WinUtilFaviconUrl -Link $app.link [void]$icon.Children.Add($logo) } [void]$contentPanel.Children.Add($icon) @@ -110,5 +110,8 @@ function Initialize-InstallAppEntry { } # Add the border to the corresponding Category $TargetElement.Children.Add($border) | Out-Null + if ($faviconUrl) { + Invoke-WinUtilFaviconFetch -AppKey $appKey -Url $faviconUrl -TargetImage $logo -Fallback $fallback + } return $checkbox } diff --git a/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 b/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 new file mode 100644 index 0000000000..af7d883b12 --- /dev/null +++ b/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 @@ -0,0 +1,23 @@ +function Initialize-WinUtilFaviconRunspacePool { + if ($sync.FaviconRunspace -and $sync.FaviconRunspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) { + return $sync.FaviconRunspace + } + + if ($sync.FaviconRunspace) { + Close-WinUtilFaviconRunspacePool + } + + $halfProcessors = [Math]::Floor([Environment]::ProcessorCount / 2) + $maxThreads = [Math]::Max($halfProcessors, 2) + $maxThreads = [Math]::Min($maxThreads, 8) + $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + + $sync.FaviconRunspace = [runspacefactory]::CreateRunspacePool( + 1, + $maxThreads, + $initialSessionState, + $Host + ) + $sync.FaviconRunspace.Open() + return $sync.FaviconRunspace +} diff --git a/functions/private/Invoke-WinUtilFaviconFetch.ps1 b/functions/private/Invoke-WinUtilFaviconFetch.ps1 new file mode 100644 index 0000000000..9a995ee394 --- /dev/null +++ b/functions/private/Invoke-WinUtilFaviconFetch.ps1 @@ -0,0 +1,127 @@ +function Complete-WinUtilFaviconFetch { + param( + [Parameter(Mandatory = $true)] + $Operation + ) + + try { + $results = @($Operation.PowerShell.EndInvoke($Operation.Handle)) + if ($results.Count -gt 0 -and $null -ne $results[0]) { + $Operation.Bytes = [byte[]]$results[0] + } + + if ($Operation.Bytes -and $Operation.Bytes.Length -gt 0) { + try { + $bitmap = [Windows.Media.Imaging.BitmapImage]::new() + $bitmap.BeginInit() + $bitmap.CacheOption = [Windows.Media.Imaging.BitmapCacheOption]::OnLoad + $bitmap.StreamSource = [System.IO.MemoryStream]::new($Operation.Bytes, $false) + $bitmap.EndInit() + $bitmap.Freeze() + $Operation.TargetImage.Source = $bitmap + $Operation.TargetImage.Visibility = [Windows.Visibility]::Visible + $Operation.Fallback.Visibility = [Windows.Visibility]::Collapsed + } catch { + $Operation.TargetImage.Visibility = [Windows.Visibility]::Collapsed + $Operation.Fallback.Visibility = [Windows.Visibility]::Visible + } + } else { + $Operation.TargetImage.Visibility = [Windows.Visibility]::Collapsed + $Operation.Fallback.Visibility = [Windows.Visibility]::Visible + } + } catch { + $Operation.TargetImage.Visibility = [Windows.Visibility]::Collapsed + $Operation.Fallback.Visibility = [Windows.Visibility]::Visible + } finally { + $Operation.PowerShell.Dispose() + $Operation.Sync.FaviconOperations.Remove($Operation.AppKey) + } +} + +function Start-WinUtilFaviconPolling { + if ($sync.FaviconTimer) { + return + } + + $sync.FaviconTimer = [System.Windows.Threading.DispatcherTimer]::new() + $sync.FaviconTimer.Interval = [TimeSpan]::FromMilliseconds(50) + $sync.FaviconTimer.Add_Tick({ + foreach ($operation in @($sync.FaviconOperations.Values)) { + if ($operation.Handle.IsCompleted) { + Complete-WinUtilFaviconFetch -Operation $operation + } + } + + if ($sync.FaviconOperations.Count -eq 0) { + $sync.FaviconTimer.Stop() + $sync.Remove("FaviconTimer") + } + }) + $sync.FaviconTimer.Start() +} + +function Invoke-WinUtilFaviconFetch { + param( + [Parameter(Mandatory = $true)] + [string]$AppKey, + + [Parameter(Mandatory = $true)] + [string]$Url, + + [Parameter(Mandatory = $true)] + [Windows.Controls.Image]$TargetImage, + + [Parameter(Mandatory = $true)] + [Windows.Controls.TextBlock]$Fallback + ) + + Initialize-WinUtilFaviconRunspacePool | Out-Null + + if ($null -eq $sync.FaviconOperations) { + $sync.FaviconOperations = [hashtable]::Synchronized(@{}) + } + + $powershell = [powershell]::Create() + [void]$powershell.AddScript({ + param($faviconUrl, $connectionLimit) + + $response = $null + $stream = $null + $memoryStream = $null + try { + $request = [System.Net.WebRequest]::Create($faviconUrl) + $request.Timeout = 5000 + $request.ReadWriteTimeout = 5000 + $request.UserAgent = "WinUtil" + $request.ServicePoint.ConnectionLimit = $connectionLimit + $response = $request.GetResponse() + $stream = $response.GetResponseStream() + $memoryStream = [System.IO.MemoryStream]::new() + $stream.CopyTo($memoryStream) + return ,$memoryStream.ToArray() + } catch { + return $null + } finally { + if ($stream) { $stream.Dispose() } + if ($response) { $response.Dispose() } + if ($memoryStream) { $memoryStream.Dispose() } + } + }) + [void]$powershell.AddArgument($Url) + [void]$powershell.AddArgument($sync.FaviconRunspace.GetMaxRunspaces()) + $powershell.RunspacePool = $sync.FaviconRunspace + + $operation = [pscustomobject]@{ + AppKey = $AppKey + PowerShell = $powershell + Handle = $null + Sync = $sync + TargetImage = $TargetImage + Fallback = $Fallback + Bytes = $null + } + + $sync.FaviconOperations[$AppKey] = $operation + $operation.Handle = $powershell.BeginInvoke() + Start-WinUtilFaviconPolling +} diff --git a/pester/favicon-loading.Tests.ps1 b/pester/favicon-loading.Tests.ps1 new file mode 100644 index 0000000000..c863dc20c9 --- /dev/null +++ b/pester/favicon-loading.Tests.ps1 @@ -0,0 +1,86 @@ +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Get-WinUtilFaviconUrl.ps1") +} + +Describe "WinUtil favicon loading" { + It "builds a favicon URL from an application link" { + Get-WinUtilFaviconUrl -Link "https://example.com/path?a=1&b=2" | + Should -Be "https://www.google.com/s2/favicons?sz=64&domain_url=https%3A%2F%2Fexample.com%2Fpath%3Fa%3D1%26b%3D2" + } + + It "returns no URL for a blank application link" { + Get-WinUtilFaviconUrl -Link " " | Should -BeNullOrEmpty + } + + It "uses a dedicated pool capped between two and eight workers" { + $poolScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilFaviconRunspacePool.ps1") -Raw + + $poolScript | Should -Match '\[Environment\]::ProcessorCount / 2' + $poolScript | Should -Match '\[Math\]::Max\(\$halfProcessors, 2\)' + $poolScript | Should -Match '\[Math\]::Min\(\$maxThreads, 8\)' + $poolScript | Should -Match 'CreateRunspacePool' + } + + It "downloads favicon bytes without assigning a network URL to the WPF image" { + $fetchScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilFaviconFetch.ps1") -Raw + $entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw + + $fetchScript | Should -Match 'WebRequest\]::Create' + $fetchScript | Should -Match 'AddScript' + $fetchScript | Should -Match 'ServicePoint\.ConnectionLimit = \$connectionLimit' + $fetchScript | Should -Match 'FaviconRunspace\.GetMaxRunspaces\(\)' + $fetchScript | Should -Match 'return ,\$memoryStream\.ToArray\(\)' + $entryScript | Should -Match 'Get-WinUtilFaviconUrl -Link \$app\.link' + $entryScript | Should -Match 'Invoke-WinUtilFaviconFetch -AppKey \$appKey' + $entryScript | Should -Not -Match '\$logo\.Source = "https://www\.google\.com/s2/favicons' + } + + It "keeps byte arrays returned directly from a runspace" { + $pool = [runspacefactory]::CreateRunspacePool(1, 1) + $pool.Open() + $powershell = [powershell]::Create() + $powershell.RunspacePool = $pool + [void]$powershell.AddScript({ + $bytes = [byte[]](1, 2, 3, 4) + return ,$bytes + }) + + try { + $handle = $powershell.BeginInvoke() + $results = @($powershell.EndInvoke($handle)) + + $results.Count | Should -Be 1 + $results[0].GetType() | Should -Be ([byte[]]) + ([byte[]]$results[0]).Length | Should -Be 4 + [Convert]::ToBase64String([byte[]]$results[0]) | Should -Be "AQIDBA==" + } finally { + $powershell.Dispose() + $pool.Close() + $pool.Dispose() + } + } + + It "applies results and fallback state through the WPF dispatcher" { + $fetchScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilFaviconFetch.ps1") -Raw + + $fetchScript | Should -Match 'DispatcherTimer' + $fetchScript | Should -Match 'Handle\.IsCompleted' + $fetchScript | Should -Match '\$Operation\.Bytes = \[byte\[\]\]\$results\[0\]' + $fetchScript | Should -Not -Match '\$results\[0\]\.BaseObject' + $fetchScript | Should -Match 'BitmapImage\]::new\(\)' + $fetchScript | Should -Match 'BitmapCacheOption\]::OnLoad' + $fetchScript | Should -Match 'TargetImage\.Visibility = \[Windows\.Visibility\]::Collapsed' + $fetchScript | Should -Match 'Fallback\.Visibility = \[Windows\.Visibility\]::Visible' + } + + It "closes favicon workers when the form closes" { + $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw + $closeScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Close-WinUtilFaviconRunspacePool.ps1") -Raw + + $mainScript | Should -Match 'Add_Closing\(\{\s+Close-WinUtilFaviconRunspacePool' + $closeScript | Should -Match '\.Stop\(\)' + $closeScript | Should -Match '\.Dispose\(\)' + $closeScript | Should -Match 'FaviconRunspace\.Close\(\)' + } +} diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 736c4bed6d..73c0f89fd3 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -449,6 +449,9 @@ Describe "XAML and sync wiring" { "ToggleStatusCache", "InstallAppRenderQueue", "InstallAppEntriesRendered", + "FaviconOperations", + "FaviconRunspace", + "FaviconTimer", "FontScaleFactor", "Win11ISOImageInfo", "Win11ISODriveLetter", diff --git a/scripts/main.ps1 b/scripts/main.ps1 index 23f42f528c..e463158b34 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -183,6 +183,7 @@ Set-WinUtilTaskbaritem -state "None" $sync["Form"].title = $sync["Form"].title + " " + $sync.version # Set the commands that will run when the form is closed $sync["Form"].Add_Closing({ + Close-WinUtilFaviconRunspacePool Close-WinUtilRunspacePool [System.GC]::Collect() }) From b6b6a52cbe3c74fe795c87eb0e8bdcb25b692cca Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:45:32 -0400 Subject: [PATCH 2/9] perf: add favicon request circuit breaker --- .../Close-WinUtilFaviconRunspacePool.ps1 | 13 ++ functions/private/Get-WinUtilFaviconUrl.ps1 | 9 +- .../Initialize-WinUtilFaviconRunspacePool.ps1 | 14 +- .../private/Invoke-WinUtilFaviconFetch.ps1 | 168 ++++++++++++++++-- pester/favicon-loading.Tests.ps1 | 105 ++++++++++- pester/xaml.Tests.ps1 | 1 + 6 files changed, 288 insertions(+), 22 deletions(-) diff --git a/functions/private/Close-WinUtilFaviconRunspacePool.ps1 b/functions/private/Close-WinUtilFaviconRunspacePool.ps1 index 3fdb40d82f..c5050537f6 100644 --- a/functions/private/Close-WinUtilFaviconRunspacePool.ps1 +++ b/functions/private/Close-WinUtilFaviconRunspacePool.ps1 @@ -1,8 +1,16 @@ function Close-WinUtilFaviconRunspacePool { + <# + .SYNOPSIS + Stops favicon work and disposes its timer, operations, circuit breaker, and runspace pool. + #> if ($null -eq $sync) { return } + if ($sync.FaviconCircuitBreaker) { + $sync.FaviconCircuitBreaker.Cancel() + } + if ($sync.FaviconTimer) { $sync.FaviconTimer.Stop() $sync.Remove("FaviconTimer") @@ -36,4 +44,9 @@ function Close-WinUtilFaviconRunspacePool { $sync.Remove("FaviconRunspace") } } + + if ($sync.FaviconCircuitBreaker) { + $sync.FaviconCircuitBreaker.Dispose() + $sync.Remove("FaviconCircuitBreaker") + } } diff --git a/functions/private/Get-WinUtilFaviconUrl.ps1 b/functions/private/Get-WinUtilFaviconUrl.ps1 index 4a2902ac0a..998f7f4d37 100644 --- a/functions/private/Get-WinUtilFaviconUrl.ps1 +++ b/functions/private/Get-WinUtilFaviconUrl.ps1 @@ -1,4 +1,10 @@ function Get-WinUtilFaviconUrl { + <# + .SYNOPSIS + Builds the Google favicon service URL for an application link. + .PARAMETER Link + The application website URL whose favicon should be requested. + #> param( [Parameter(Mandatory = $true)] [string]$Link @@ -8,5 +14,6 @@ function Get-WinUtilFaviconUrl { return $null } - return "https://www.google.com/s2/favicons?sz=64&domain_url=$([uri]::EscapeDataString($Link))" + $faviconSize = 64 + return "https://www.google.com/s2/favicons?sz=$faviconSize&domain_url=$([uri]::EscapeDataString($Link))" } diff --git a/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 b/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 index af7d883b12..5f637bdca4 100644 --- a/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 +++ b/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 @@ -1,4 +1,12 @@ function Initialize-WinUtilFaviconRunspacePool { + <# + .SYNOPSIS + Creates or returns the dedicated runspace pool used for favicon downloads. + .DESCRIPTION + Uses half the available logical processors while keeping concurrency between + two and eight workers so favicon requests remain responsive without creating + an excessive burst of connections. + #> if ($sync.FaviconRunspace -and $sync.FaviconRunspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) { return $sync.FaviconRunspace } @@ -7,9 +15,11 @@ function Initialize-WinUtilFaviconRunspacePool { Close-WinUtilFaviconRunspacePool } + $minimumWorkers = 2 + $maximumWorkers = 8 $halfProcessors = [Math]::Floor([Environment]::ProcessorCount / 2) - $maxThreads = [Math]::Max($halfProcessors, 2) - $maxThreads = [Math]::Min($maxThreads, 8) + $maxThreads = [Math]::Max($halfProcessors, $minimumWorkers) + $maxThreads = [Math]::Min($maxThreads, $maximumWorkers) $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() $sync.FaviconRunspace = [runspacefactory]::CreateRunspacePool( diff --git a/functions/private/Invoke-WinUtilFaviconFetch.ps1 b/functions/private/Invoke-WinUtilFaviconFetch.ps1 index 9a995ee394..c7f904248b 100644 --- a/functions/private/Invoke-WinUtilFaviconFetch.ps1 +++ b/functions/private/Invoke-WinUtilFaviconFetch.ps1 @@ -1,4 +1,105 @@ +function Initialize-WinUtilFaviconCircuitBreaker { + <# + .SYNOPSIS + Creates the shared thread-safe circuit breaker used by favicon workers. + #> + if ($sync.FaviconCircuitBreaker) { + return + } + + if (-not ("WinUtilFaviconCircuitBreaker" -as [type])) { + # Workers share this compiled reference type so failure counting and cancellation + # remain atomic across runspaces without relying on PowerShell thread affinity. + Add-Type @" +using System; +using System.Threading; + +public sealed class WinUtilFaviconCircuitBreaker : IDisposable +{ + private readonly object gate = new object(); + private readonly int threshold; + private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); + private int consecutiveFailures; + + public WinUtilFaviconCircuitBreaker(int threshold) + { + if (threshold <= 0) + { + throw new ArgumentOutOfRangeException("threshold", "Threshold must be greater than zero."); + } + + this.threshold = threshold; + } + + public bool IsCancellationRequested + { + get { return cancellation.IsCancellationRequested; } + } + + public int ConsecutiveFailures + { + get + { + lock (gate) + { + return consecutiveFailures; + } + } + } + + public void ReportSuccess() + { + lock (gate) + { + if (!cancellation.IsCancellationRequested) + { + consecutiveFailures = 0; + } + } + } + + public void ReportFailure() + { + lock (gate) + { + if (cancellation.IsCancellationRequested) + { + return; + } + + consecutiveFailures++; + if (consecutiveFailures >= threshold) + { + cancellation.Cancel(); + } + } + } + + public void Cancel() + { + cancellation.Cancel(); + } + + public void Dispose() + { + cancellation.Dispose(); + } +} +"@ + } + + # Eight consecutive transport failures strongly indicate that Google is unavailable. + $failureThreshold = 8 + $sync.FaviconCircuitBreaker = [WinUtilFaviconCircuitBreaker]::new($failureThreshold) +} + function Complete-WinUtilFaviconFetch { + <# + .SYNOPSIS + Completes one favicon operation and updates its WPF image or fallback text. + .PARAMETER Operation + The asynchronous favicon operation and its associated WPF controls. + #> param( [Parameter(Mandatory = $true)] $Operation @@ -6,11 +107,14 @@ function Complete-WinUtilFaviconFetch { try { $results = @($Operation.PowerShell.EndInvoke($Operation.Handle)) - if ($results.Count -gt 0 -and $null -ne $results[0]) { - $Operation.Bytes = [byte[]]$results[0] + if ($results.Count -eq 0 -or $null -eq $results[0]) { + throw "Favicon worker returned no result." } - if ($Operation.Bytes -and $Operation.Bytes.Length -gt 0) { + $result = $results[0] + + if ($result.Status -eq "Success" -and $result.Bytes) { + $Operation.Bytes = [byte[]]$result.Bytes try { $bitmap = [Windows.Media.Imaging.BitmapImage]::new() $bitmap.BeginInit() @@ -39,12 +143,17 @@ function Complete-WinUtilFaviconFetch { } function Start-WinUtilFaviconPolling { + <# + .SYNOPSIS + Starts dispatcher-based polling for completed favicon operations. + #> if ($sync.FaviconTimer) { return } + $pollIntervalMilliseconds = 50 $sync.FaviconTimer = [System.Windows.Threading.DispatcherTimer]::new() - $sync.FaviconTimer.Interval = [TimeSpan]::FromMilliseconds(50) + $sync.FaviconTimer.Interval = [TimeSpan]::FromMilliseconds($pollIntervalMilliseconds) $sync.FaviconTimer.Add_Tick({ foreach ($operation in @($sync.FaviconOperations.Values)) { if ($operation.Handle.IsCompleted) { @@ -61,6 +170,18 @@ function Start-WinUtilFaviconPolling { } function Invoke-WinUtilFaviconFetch { + <# + .SYNOPSIS + Queues one application favicon for bounded background downloading. + .PARAMETER AppKey + The unique application key associated with the favicon operation. + .PARAMETER Url + The favicon service URL to download. + .PARAMETER TargetImage + The WPF image control that receives the downloaded favicon. + .PARAMETER Fallback + The WPF text control displayed when the favicon is unavailable. + #> param( [Parameter(Mandatory = $true)] [string]$AppKey, @@ -75,32 +196,52 @@ function Invoke-WinUtilFaviconFetch { [Windows.Controls.TextBlock]$Fallback ) - Initialize-WinUtilFaviconRunspacePool | Out-Null + Initialize-WinUtilFaviconCircuitBreaker + if ($sync.FaviconCircuitBreaker.IsCancellationRequested) { + return + } + Initialize-WinUtilFaviconRunspacePool | Out-Null if ($null -eq $sync.FaviconOperations) { $sync.FaviconOperations = [hashtable]::Synchronized(@{}) } + $requestTimeoutMilliseconds = 5000 $powershell = [powershell]::Create() [void]$powershell.AddScript({ - param($faviconUrl, $connectionLimit) + param($faviconUrl, $connectionLimit, $circuitBreaker, $requestTimeoutMilliseconds) + + if ($circuitBreaker.IsCancellationRequested) { + return [pscustomobject]@{ + Status = "Cancelled" + Bytes = $null + } + } $response = $null $stream = $null $memoryStream = $null try { $request = [System.Net.WebRequest]::Create($faviconUrl) - $request.Timeout = 5000 - $request.ReadWriteTimeout = 5000 + $request.Timeout = $requestTimeoutMilliseconds + $request.ReadWriteTimeout = $requestTimeoutMilliseconds $request.UserAgent = "WinUtil" $request.ServicePoint.ConnectionLimit = $connectionLimit $response = $request.GetResponse() $stream = $response.GetResponseStream() $memoryStream = [System.IO.MemoryStream]::new() $stream.CopyTo($memoryStream) - return ,$memoryStream.ToArray() + $circuitBreaker.ReportSuccess() + return [pscustomobject]@{ + Status = "Success" + Bytes = $memoryStream.ToArray() + } } catch { - return $null + $circuitBreaker.ReportFailure() + return [pscustomobject]@{ + Status = "NetworkFailure" + Bytes = $null + } } finally { if ($stream) { $stream.Dispose() } if ($response) { $response.Dispose() } @@ -109,6 +250,8 @@ function Invoke-WinUtilFaviconFetch { }) [void]$powershell.AddArgument($Url) [void]$powershell.AddArgument($sync.FaviconRunspace.GetMaxRunspaces()) + [void]$powershell.AddArgument($sync.FaviconCircuitBreaker) + [void]$powershell.AddArgument($requestTimeoutMilliseconds) $powershell.RunspacePool = $sync.FaviconRunspace $operation = [pscustomobject]@{ @@ -121,6 +264,11 @@ function Invoke-WinUtilFaviconFetch { Bytes = $null } + if ($sync.FaviconCircuitBreaker.IsCancellationRequested) { + $powershell.Dispose() + return + } + $sync.FaviconOperations[$AppKey] = $operation $operation.Handle = $powershell.BeginInvoke() Start-WinUtilFaviconPolling diff --git a/pester/favicon-loading.Tests.ps1 b/pester/favicon-loading.Tests.ps1 index c863dc20c9..f44066b42d 100644 --- a/pester/favicon-loading.Tests.ps1 +++ b/pester/favicon-loading.Tests.ps1 @@ -1,6 +1,8 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + Add-Type -AssemblyName PresentationFramework . (Join-Path $script:repoRoot "functions\private\Get-WinUtilFaviconUrl.ps1") + . (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilFaviconFetch.ps1") } Describe "WinUtil favicon loading" { @@ -17,8 +19,10 @@ Describe "WinUtil favicon loading" { $poolScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilFaviconRunspacePool.ps1") -Raw $poolScript | Should -Match '\[Environment\]::ProcessorCount / 2' - $poolScript | Should -Match '\[Math\]::Max\(\$halfProcessors, 2\)' - $poolScript | Should -Match '\[Math\]::Min\(\$maxThreads, 8\)' + $poolScript | Should -Match '\$minimumWorkers = 2' + $poolScript | Should -Match '\$maximumWorkers = 8' + $poolScript | Should -Match '\[Math\]::Max\(\$halfProcessors, \$minimumWorkers\)' + $poolScript | Should -Match '\[Math\]::Min\(\$maxThreads, \$maximumWorkers\)' $poolScript | Should -Match 'CreateRunspacePool' } @@ -30,20 +34,25 @@ Describe "WinUtil favicon loading" { $fetchScript | Should -Match 'AddScript' $fetchScript | Should -Match 'ServicePoint\.ConnectionLimit = \$connectionLimit' $fetchScript | Should -Match 'FaviconRunspace\.GetMaxRunspaces\(\)' - $fetchScript | Should -Match 'return ,\$memoryStream\.ToArray\(\)' + $fetchScript | Should -Match 'Status = "Success"' + $fetchScript | Should -Match 'Status = "NetworkFailure"' + $fetchScript | Should -Match 'Status = "Cancelled"' $entryScript | Should -Match 'Get-WinUtilFaviconUrl -Link \$app\.link' $entryScript | Should -Match 'Invoke-WinUtilFaviconFetch -AppKey \$appKey' $entryScript | Should -Not -Match '\$logo\.Source = "https://www\.google\.com/s2/favicons' } - It "keeps byte arrays returned directly from a runspace" { + It "keeps byte arrays inside structured runspace results" { $pool = [runspacefactory]::CreateRunspacePool(1, 1) $pool.Open() $powershell = [powershell]::Create() $powershell.RunspacePool = $pool [void]$powershell.AddScript({ $bytes = [byte[]](1, 2, 3, 4) - return ,$bytes + return [pscustomobject]@{ + Status = "Success" + Bytes = $bytes + } }) try { @@ -51,9 +60,9 @@ Describe "WinUtil favicon loading" { $results = @($powershell.EndInvoke($handle)) $results.Count | Should -Be 1 - $results[0].GetType() | Should -Be ([byte[]]) - ([byte[]]$results[0]).Length | Should -Be 4 - [Convert]::ToBase64String([byte[]]$results[0]) | Should -Be "AQIDBA==" + $results[0].Status | Should -Be "Success" + ([byte[]]$results[0].Bytes).Length | Should -Be 4 + [Convert]::ToBase64String([byte[]]$results[0].Bytes) | Should -Be "AQIDBA==" } finally { $powershell.Dispose() $pool.Close() @@ -61,12 +70,89 @@ Describe "WinUtil favicon loading" { } } + It "opens the circuit after eight consecutive network failures" { + $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + try { + $global:sync = [hashtable]::Synchronized(@{}) + Initialize-WinUtilFaviconCircuitBreaker + + 1..7 | ForEach-Object { $global:sync.FaviconCircuitBreaker.ReportFailure() } + $global:sync.FaviconCircuitBreaker.IsCancellationRequested | Should -BeFalse + + $global:sync.FaviconCircuitBreaker.ReportFailure() + $global:sync.FaviconCircuitBreaker.IsCancellationRequested | Should -BeTrue + } finally { + if ($global:sync.FaviconCircuitBreaker) { + $global:sync.FaviconCircuitBreaker.Dispose() + } + if ($previousSync) { + Set-Variable -Name sync -Value $previousSync.Value -Scope Global + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + } + } + + It "rejects a circuit threshold that is not greater than zero" { + $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + try { + $global:sync = [hashtable]::Synchronized(@{}) + Initialize-WinUtilFaviconCircuitBreaker + $global:sync.FaviconCircuitBreaker.Dispose() + + { [WinUtilFaviconCircuitBreaker]::new(0) } | Should -Throw "*Threshold must be greater than zero*" + } finally { + if ($previousSync) { + Set-Variable -Name sync -Value $previousSync.Value -Scope Global + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + } + } + + It "resets consecutive failures on success and ignores cancellations" { + $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + try { + $global:sync = [hashtable]::Synchronized(@{}) + Initialize-WinUtilFaviconCircuitBreaker + + 1..7 | ForEach-Object { $global:sync.FaviconCircuitBreaker.ReportFailure() } + $global:sync.FaviconCircuitBreaker.ReportSuccess() + 1..7 | ForEach-Object { $global:sync.FaviconCircuitBreaker.ReportFailure() } + + $global:sync.FaviconCircuitBreaker.ConsecutiveFailures | Should -Be 7 + $global:sync.FaviconCircuitBreaker.IsCancellationRequested | Should -BeFalse + } finally { + if ($global:sync.FaviconCircuitBreaker) { + $global:sync.FaviconCircuitBreaker.Dispose() + } + if ($previousSync) { + Set-Variable -Name sync -Value $previousSync.Value -Scope Global + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + } + } + + It "checks the shared breaker before scheduling and again inside each worker" { + $fetchScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilFaviconFetch.ps1") -Raw + $scheduleCheck = $fetchScript.IndexOf('if ($sync.FaviconCircuitBreaker.IsCancellationRequested)') + $beginInvoke = $fetchScript.IndexOf('$powershell.BeginInvoke()') + + $scheduleCheck | Should -BeGreaterOrEqual 0 + $scheduleCheck | Should -BeLessThan $beginInvoke + $fetchScript | Should -Match 'if \(\$circuitBreaker\.IsCancellationRequested\)' + $fetchScript | Should -Match '\$circuitBreaker\.ReportSuccess\(\)' + $fetchScript | Should -Match '\$circuitBreaker\.ReportFailure\(\)' + $fetchScript | Should -Match '\$failureThreshold = 8' + } + It "applies results and fallback state through the WPF dispatcher" { $fetchScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilFaviconFetch.ps1") -Raw $fetchScript | Should -Match 'DispatcherTimer' $fetchScript | Should -Match 'Handle\.IsCompleted' - $fetchScript | Should -Match '\$Operation\.Bytes = \[byte\[\]\]\$results\[0\]' + $fetchScript | Should -Match '\$Operation\.Bytes = \[byte\[\]\]\$result\.Bytes' $fetchScript | Should -Not -Match '\$results\[0\]\.BaseObject' $fetchScript | Should -Match 'BitmapImage\]::new\(\)' $fetchScript | Should -Match 'BitmapCacheOption\]::OnLoad' @@ -80,6 +166,7 @@ Describe "WinUtil favicon loading" { $mainScript | Should -Match 'Add_Closing\(\{\s+Close-WinUtilFaviconRunspacePool' $closeScript | Should -Match '\.Stop\(\)' + $closeScript | Should -Match 'FaviconCircuitBreaker\.Cancel\(\)' $closeScript | Should -Match '\.Dispose\(\)' $closeScript | Should -Match 'FaviconRunspace\.Close\(\)' } diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 73c0f89fd3..9a50fa9788 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -450,6 +450,7 @@ Describe "XAML and sync wiring" { "InstallAppRenderQueue", "InstallAppEntriesRendered", "FaviconOperations", + "FaviconCircuitBreaker", "FaviconRunspace", "FaviconTimer", "FontScaleFactor", From 1837a8dbaf29dbb0c5cbfb7043301cc035f48bec Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:50:40 -0400 Subject: [PATCH 3/9] fix: preserve favicon state when replacing stale pool --- .../Initialize-WinUtilFaviconRunspacePool.ps1 | 13 ++++++- pester/favicon-loading.Tests.ps1 | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 b/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 index 5f637bdca4..8a475c7b10 100644 --- a/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 +++ b/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 @@ -12,7 +12,18 @@ function Initialize-WinUtilFaviconRunspacePool { } if ($sync.FaviconRunspace) { - Close-WinUtilFaviconRunspacePool + try { + if ($sync.FaviconRunspace.RunspacePoolStateInfo.State -notin @( + [System.Management.Automation.Runspaces.RunspacePoolState]::Closed, + [System.Management.Automation.Runspaces.RunspacePoolState]::Closing, + [System.Management.Automation.Runspaces.RunspacePoolState]::Broken + )) { + $sync.FaviconRunspace.Close() + } + } finally { + $sync.FaviconRunspace.Dispose() + $sync.Remove("FaviconRunspace") + } } $minimumWorkers = 2 diff --git a/pester/favicon-loading.Tests.ps1 b/pester/favicon-loading.Tests.ps1 index f44066b42d..0b926d1c11 100644 --- a/pester/favicon-loading.Tests.ps1 +++ b/pester/favicon-loading.Tests.ps1 @@ -2,6 +2,7 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path Add-Type -AssemblyName PresentationFramework . (Join-Path $script:repoRoot "functions\private\Get-WinUtilFaviconUrl.ps1") + . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilFaviconRunspacePool.ps1") . (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilFaviconFetch.ps1") } @@ -26,6 +27,39 @@ Describe "WinUtil favicon loading" { $poolScript | Should -Match 'CreateRunspacePool' } + It "replaces a stale pool without clearing shared favicon state" { + $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + $stalePool = [runspacefactory]::CreateRunspacePool(1, 1) + $stalePool.Open() + $stalePool.Close() + $circuitBreaker = [pscustomobject]@{ Name = "Test breaker" } + $operations = [hashtable]::Synchronized(@{}) + + try { + $global:sync = [hashtable]::Synchronized(@{ + FaviconCircuitBreaker = $circuitBreaker + FaviconOperations = $operations + FaviconRunspace = $stalePool + }) + + $replacementPool = Initialize-WinUtilFaviconRunspacePool + + $replacementPool.RunspacePoolStateInfo.State | Should -Be ([System.Management.Automation.Runspaces.RunspacePoolState]::Opened) + [object]::ReferenceEquals($global:sync.FaviconCircuitBreaker, $circuitBreaker) | Should -BeTrue + [object]::ReferenceEquals($global:sync.FaviconOperations, $operations) | Should -BeTrue + } finally { + if ($global:sync.FaviconRunspace) { + $global:sync.FaviconRunspace.Close() + $global:sync.FaviconRunspace.Dispose() + } + if ($previousSync) { + $global:sync = $previousSync.Value + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + } + } + It "downloads favicon bytes without assigning a network URL to the WPF image" { $fetchScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilFaviconFetch.ps1") -Raw $entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw From 1ea6d990e960a5dcea3f3b8a2de1c33f46db227e Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:22:21 -0400 Subject: [PATCH 4/9] fix: isolate favicon setup failures --- functions/private/Initialize-InstallAppEntry.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/functions/private/Initialize-InstallAppEntry.ps1 b/functions/private/Initialize-InstallAppEntry.ps1 index 711c6dbe8b..6f4157e617 100644 --- a/functions/private/Initialize-InstallAppEntry.ps1 +++ b/functions/private/Initialize-InstallAppEntry.ps1 @@ -111,7 +111,13 @@ function Initialize-InstallAppEntry { # Add the border to the corresponding Category $TargetElement.Children.Add($border) | Out-Null if ($faviconUrl) { - Invoke-WinUtilFaviconFetch -AppKey $appKey -Url $faviconUrl -TargetImage $logo -Fallback $fallback + try { + Invoke-WinUtilFaviconFetch -AppKey $appKey -Url $faviconUrl -TargetImage $logo -Fallback $fallback + } catch { + # Favicon loading is optional; keep the fallback visible if setup fails. + $logo.Visibility = [Windows.Visibility]::Collapsed + $fallback.Visibility = [Windows.Visibility]::Visible + } } return $checkbox } From 196854264eda17c714fa293e5ef951801a6fe27b Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:25:45 -0400 Subject: [PATCH 5/9] fix: validate favicon images before reset --- functions/private/Invoke-WinUtilFaviconFetch.ps1 | 3 ++- pester/favicon-loading.Tests.ps1 | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/functions/private/Invoke-WinUtilFaviconFetch.ps1 b/functions/private/Invoke-WinUtilFaviconFetch.ps1 index c7f904248b..35d68e39d4 100644 --- a/functions/private/Invoke-WinUtilFaviconFetch.ps1 +++ b/functions/private/Invoke-WinUtilFaviconFetch.ps1 @@ -122,10 +122,12 @@ function Complete-WinUtilFaviconFetch { $bitmap.StreamSource = [System.IO.MemoryStream]::new($Operation.Bytes, $false) $bitmap.EndInit() $bitmap.Freeze() + $Operation.Sync.FaviconCircuitBreaker.ReportSuccess() $Operation.TargetImage.Source = $bitmap $Operation.TargetImage.Visibility = [Windows.Visibility]::Visible $Operation.Fallback.Visibility = [Windows.Visibility]::Collapsed } catch { + $Operation.Sync.FaviconCircuitBreaker.ReportFailure() $Operation.TargetImage.Visibility = [Windows.Visibility]::Collapsed $Operation.Fallback.Visibility = [Windows.Visibility]::Visible } @@ -231,7 +233,6 @@ function Invoke-WinUtilFaviconFetch { $stream = $response.GetResponseStream() $memoryStream = [System.IO.MemoryStream]::new() $stream.CopyTo($memoryStream) - $circuitBreaker.ReportSuccess() return [pscustomobject]@{ Status = "Success" Bytes = $memoryStream.ToArray() diff --git a/pester/favicon-loading.Tests.ps1 b/pester/favicon-loading.Tests.ps1 index 0b926d1c11..3f8e6b04f6 100644 --- a/pester/favicon-loading.Tests.ps1 +++ b/pester/favicon-loading.Tests.ps1 @@ -176,7 +176,9 @@ Describe "WinUtil favicon loading" { $scheduleCheck | Should -BeGreaterOrEqual 0 $scheduleCheck | Should -BeLessThan $beginInvoke $fetchScript | Should -Match 'if \(\$circuitBreaker\.IsCancellationRequested\)' - $fetchScript | Should -Match '\$circuitBreaker\.ReportSuccess\(\)' + $fetchScript | Should -Not -Match '\$circuitBreaker\.ReportSuccess\(\)' + $fetchScript | Should -Match '\$Operation\.Sync\.FaviconCircuitBreaker\.ReportSuccess\(\)' + $fetchScript | Should -Match '\$Operation\.Sync\.FaviconCircuitBreaker\.ReportFailure\(\)' $fetchScript | Should -Match '\$circuitBreaker\.ReportFailure\(\)' $fetchScript | Should -Match '\$failureThreshold = 8' } From 9f5716180f21829b469e3385cfb2c4c35f8c631b Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:39:08 -0400 Subject: [PATCH 6/9] fix: clean up failed favicon operations --- .../private/Invoke-WinUtilFaviconFetch.ps1 | 20 ++++++++++++++++--- pester/favicon-loading.Tests.ps1 | 15 ++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/functions/private/Invoke-WinUtilFaviconFetch.ps1 b/functions/private/Invoke-WinUtilFaviconFetch.ps1 index 35d68e39d4..b552157d0d 100644 --- a/functions/private/Invoke-WinUtilFaviconFetch.ps1 +++ b/functions/private/Invoke-WinUtilFaviconFetch.ps1 @@ -132,10 +132,16 @@ function Complete-WinUtilFaviconFetch { $Operation.Fallback.Visibility = [Windows.Visibility]::Visible } } else { + if ($result.Status -notin @("NetworkFailure", "Cancelled")) { + $Operation.Sync.FaviconCircuitBreaker.ReportFailure() + } $Operation.TargetImage.Visibility = [Windows.Visibility]::Collapsed $Operation.Fallback.Visibility = [Windows.Visibility]::Visible } } catch { + if (-not $Operation.Sync.FaviconCircuitBreaker.IsCancellationRequested) { + $Operation.Sync.FaviconCircuitBreaker.ReportFailure() + } $Operation.TargetImage.Visibility = [Windows.Visibility]::Collapsed $Operation.Fallback.Visibility = [Windows.Visibility]::Visible } finally { @@ -270,7 +276,15 @@ function Invoke-WinUtilFaviconFetch { return } - $sync.FaviconOperations[$AppKey] = $operation - $operation.Handle = $powershell.BeginInvoke() - Start-WinUtilFaviconPolling + try { + $sync.FaviconOperations[$AppKey] = $operation + $operation.Handle = $powershell.BeginInvoke() + Start-WinUtilFaviconPolling + } catch { + if ($operation -and [object]::ReferenceEquals($sync.FaviconOperations[$AppKey], $operation)) { + [void]$sync.FaviconOperations.Remove($AppKey) + } + $powershell.Dispose() + throw + } } diff --git a/pester/favicon-loading.Tests.ps1 b/pester/favicon-loading.Tests.ps1 index 3f8e6b04f6..68da04b567 100644 --- a/pester/favicon-loading.Tests.ps1 +++ b/pester/favicon-loading.Tests.ps1 @@ -196,6 +196,21 @@ Describe "WinUtil favicon loading" { $fetchScript | Should -Match 'Fallback\.Visibility = \[Windows\.Visibility\]::Visible' } + It "counts unexpected completion failures without double-counting worker outcomes" { + $fetchScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilFaviconFetch.ps1") -Raw + + $fetchScript | Should -Match '\$result\.Status -notin @\("NetworkFailure", "Cancelled"\)' + $fetchScript | Should -Match 'if \(-not \$Operation\.Sync\.FaviconCircuitBreaker\.IsCancellationRequested\)' + } + + It "cleans up failed favicon submissions" { + $fetchScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilFaviconFetch.ps1") -Raw + + $fetchScript | Should -Match '\[object\]::ReferenceEquals\(\$sync\.FaviconOperations\[\$AppKey\], \$operation\)' + $fetchScript | Should -Match '\$sync\.FaviconOperations\.Remove\(\$AppKey\)' + $fetchScript | Should -Match '\$powershell\.Dispose\(\)\s+throw' + } + It "closes favicon workers when the form closes" { $mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw $closeScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Close-WinUtilFaviconRunspacePool.ps1") -Raw From 79a1b6819d753b20b2afea4449f2be0ca827a818 Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:07:07 -0400 Subject: [PATCH 7/9] perf: defer favicons until app rendering completes Render app cards with fallback letters before starting favicon work. Feed the dedicated pool from a bounded queue while preserving the circuit breaker, operation tracking, and shutdown cleanup. --- .../Close-WinUtilFaviconRunspacePool.ps1 | 5 + .../private/Initialize-InstallAppEntry.ps1 | 15 +- .../Initialize-InstallCategoryAppList.ps1 | 1 + .../private/Invoke-WinUtilFaviconFetch.ps1 | 91 +++++++- .../Start-WinUtilInstallAppRendering.ps1 | 14 ++ pester/favicon-loading.Tests.ps1 | 204 +++++++++++++++++- pester/install-rendering.Tests.ps1 | 19 +- pester/xaml.Tests.ps1 | 1 + 8 files changed, 330 insertions(+), 20 deletions(-) diff --git a/functions/private/Close-WinUtilFaviconRunspacePool.ps1 b/functions/private/Close-WinUtilFaviconRunspacePool.ps1 index c5050537f6..5fd858328b 100644 --- a/functions/private/Close-WinUtilFaviconRunspacePool.ps1 +++ b/functions/private/Close-WinUtilFaviconRunspacePool.ps1 @@ -16,6 +16,11 @@ function Close-WinUtilFaviconRunspacePool { $sync.Remove("FaviconTimer") } + if ($sync.ContainsKey("FaviconQueue") -and $null -ne $sync.FaviconQueue) { + $sync.FaviconQueue.Clear() + $sync.Remove("FaviconQueue") + } + if ($sync.FaviconOperations) { foreach ($operation in @($sync.FaviconOperations.Values)) { try { diff --git a/functions/private/Initialize-InstallAppEntry.ps1 b/functions/private/Initialize-InstallAppEntry.ps1 index 8bab79a03d..6319a81783 100644 --- a/functions/private/Initialize-InstallAppEntry.ps1 +++ b/functions/private/Initialize-InstallAppEntry.ps1 @@ -117,13 +117,16 @@ function Initialize-InstallAppEntry { # Add the border to the corresponding Category $TargetElement.Children.Add($border) | Out-Null if ($faviconUrl) { - try { - Invoke-WinUtilFaviconFetch -AppKey $appKey -Url $faviconUrl -TargetImage $logo -Fallback $fallback - } catch { - # Favicon loading is optional; keep the fallback visible if setup fails. - $logo.Visibility = [Windows.Visibility]::Collapsed - $fallback.Visibility = [Windows.Visibility]::Visible + if ($null -eq $sync.FaviconQueue) { + $sync.FaviconQueue = [System.Collections.Queue]::new() } + + $sync.FaviconQueue.Enqueue([pscustomobject]@{ + AppKey = $appKey + Url = $faviconUrl + TargetImage = $logo + Fallback = $fallback + }) } return $checkbox } diff --git a/functions/private/Initialize-InstallCategoryAppList.ps1 b/functions/private/Initialize-InstallCategoryAppList.ps1 index 66f45222ba..ea6eacfccc 100644 --- a/functions/private/Initialize-InstallCategoryAppList.ps1 +++ b/functions/private/Initialize-InstallCategoryAppList.ps1 @@ -26,6 +26,7 @@ function Initialize-InstallCategoryAppList { $appsByCategory[$category] += $appKey } $sync.InstallAppRenderQueue = [System.Collections.Queue]::new() + $sync.FaviconQueue = [System.Collections.Queue]::new() foreach ($category in $($appsByCategory.Keys | Sort-Object)) { # Create a container for category label + apps diff --git a/functions/private/Invoke-WinUtilFaviconFetch.ps1 b/functions/private/Invoke-WinUtilFaviconFetch.ps1 index b552157d0d..80832a79bd 100644 --- a/functions/private/Invoke-WinUtilFaviconFetch.ps1 +++ b/functions/private/Invoke-WinUtilFaviconFetch.ps1 @@ -147,6 +147,7 @@ function Complete-WinUtilFaviconFetch { } finally { $Operation.PowerShell.Dispose() $Operation.Sync.FaviconOperations.Remove($Operation.AppKey) + Invoke-WinUtilFaviconQueuePump } } @@ -177,10 +178,91 @@ function Start-WinUtilFaviconPolling { $sync.FaviconTimer.Start() } +function Invoke-WinUtilFaviconQueuePump { + <# + .SYNOPSIS + Fills the dedicated favicon runspace pool from the pending request queue. + #> + if ($null -eq $sync.FaviconQueue -or + $null -eq $sync.FaviconCircuitBreaker -or + $null -eq $sync.FaviconRunspace -or + $null -eq $sync.FaviconOperations) { + return + } + + if ($sync.FaviconCircuitBreaker.IsCancellationRequested) { + $sync.FaviconQueue.Clear() + return + } + + try { + $maximumActiveOperations = $sync.FaviconRunspace.GetMaxRunspaces() + } catch { + $sync.FaviconCircuitBreaker.Cancel() + $sync.FaviconQueue.Clear() + return + } + + while ($sync.FaviconQueue.Count -gt 0 -and + $sync.FaviconOperations.Count -lt $maximumActiveOperations -and + -not $sync.FaviconCircuitBreaker.IsCancellationRequested) { + $request = $sync.FaviconQueue.Dequeue() + try { + Invoke-WinUtilFaviconFetch ` + -AppKey $request.AppKey ` + -Url $request.Url ` + -TargetImage $request.TargetImage ` + -Fallback $request.Fallback + } catch { + # Submission infrastructure failed. Favicon loading is optional, so stop + # pending work while preserving the already-rendered fallback entries. + $sync.FaviconCircuitBreaker.Cancel() + $sync.FaviconQueue.Clear() + return + } + } + + if ($sync.FaviconCircuitBreaker.IsCancellationRequested) { + $sync.FaviconQueue.Clear() + } +} + +function Start-WinUtilFaviconLoading { + <# + .SYNOPSIS + Initializes favicon infrastructure and starts bounded request submission. + #> + if ($null -eq $sync.FaviconQueue -or $sync.FaviconQueue.Count -eq 0) { + return + } + + try { + Initialize-WinUtilFaviconCircuitBreaker + if ($sync.FaviconCircuitBreaker.IsCancellationRequested) { + $sync.FaviconQueue.Clear() + return + } + + Initialize-WinUtilFaviconRunspacePool | Out-Null + if ($null -eq $sync.FaviconOperations) { + $sync.FaviconOperations = [hashtable]::Synchronized(@{}) + } + + Start-WinUtilFaviconPolling + Invoke-WinUtilFaviconQueuePump + } catch { + $sync.FaviconQueue.Clear() + try { + Close-WinUtilFaviconRunspacePool + } catch { + } + } +} + function Invoke-WinUtilFaviconFetch { <# .SYNOPSIS - Queues one application favicon for bounded background downloading. + Submits one queued application favicon for background downloading. .PARAMETER AppKey The unique application key associated with the favicon operation. .PARAMETER Url @@ -204,16 +286,10 @@ function Invoke-WinUtilFaviconFetch { [Windows.Controls.TextBlock]$Fallback ) - Initialize-WinUtilFaviconCircuitBreaker if ($sync.FaviconCircuitBreaker.IsCancellationRequested) { return } - Initialize-WinUtilFaviconRunspacePool | Out-Null - if ($null -eq $sync.FaviconOperations) { - $sync.FaviconOperations = [hashtable]::Synchronized(@{}) - } - $requestTimeoutMilliseconds = 5000 $powershell = [powershell]::Create() [void]$powershell.AddScript({ @@ -279,7 +355,6 @@ function Invoke-WinUtilFaviconFetch { try { $sync.FaviconOperations[$AppKey] = $operation $operation.Handle = $powershell.BeginInvoke() - Start-WinUtilFaviconPolling } catch { if ($operation -and [object]::ReferenceEquals($sync.FaviconOperations[$AppKey], $operation)) { [void]$sync.FaviconOperations.Remove($AppKey) diff --git a/functions/private/Start-WinUtilInstallAppRendering.ps1 b/functions/private/Start-WinUtilInstallAppRendering.ps1 index 1290de0eb2..25dcdd2ca6 100644 --- a/functions/private/Start-WinUtilInstallAppRendering.ps1 +++ b/functions/private/Start-WinUtilInstallAppRendering.ps1 @@ -21,6 +21,20 @@ function Invoke-WinUtilInstallAppRenderBatch { function Complete-WinUtilInstallAppRendering { $sync.InstallAppEntriesRendered = $true + + if ($null -eq $sync.FaviconQueue -or $sync.FaviconQueue.Count -eq 0) { + return + } + + if ($sync.Form -and $sync.Form.Dispatcher) { + $sync.Form.Dispatcher.BeginInvoke( + [System.Windows.Threading.DispatcherPriority]::Background, + [action]{ Start-WinUtilFaviconLoading } + ) | Out-Null + return + } + + Start-WinUtilFaviconLoading } function Invoke-WinUtilInstallAppRenderNextBatch { diff --git a/pester/favicon-loading.Tests.ps1 b/pester/favicon-loading.Tests.ps1 index 68da04b567..4a015504e5 100644 --- a/pester/favicon-loading.Tests.ps1 +++ b/pester/favicon-loading.Tests.ps1 @@ -2,6 +2,7 @@ BeforeAll { $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path Add-Type -AssemblyName PresentationFramework . (Join-Path $script:repoRoot "functions\private\Get-WinUtilFaviconUrl.ps1") + . (Join-Path $script:repoRoot "functions\private\Close-WinUtilFaviconRunspacePool.ps1") . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilFaviconRunspacePool.ps1") . (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilFaviconFetch.ps1") } @@ -72,7 +73,8 @@ Describe "WinUtil favicon loading" { $fetchScript | Should -Match 'Status = "NetworkFailure"' $fetchScript | Should -Match 'Status = "Cancelled"' $entryScript | Should -Match 'Get-WinUtilFaviconUrl -Link \$app\.link' - $entryScript | Should -Match 'Invoke-WinUtilFaviconFetch -AppKey \$appKey' + $entryScript | Should -Match '\$sync\.FaviconQueue\.Enqueue' + $entryScript | Should -Not -Match 'Invoke-WinUtilFaviconFetch' $entryScript | Should -Not -Match '\$logo\.Source = "https://www\.google\.com/s2/favicons' } @@ -183,6 +185,188 @@ Describe "WinUtil favicon loading" { $fetchScript | Should -Match '\$failureThreshold = 8' } + It "submits only enough requests to fill the dedicated pool" { + $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + try { + $breaker = [pscustomobject]@{ IsCancellationRequested = $false } + $breaker | Add-Member -MemberType ScriptMethod -Name Cancel -Value { $this.IsCancellationRequested = $true } + $pool = [pscustomobject]@{} + $pool | Add-Member -MemberType ScriptMethod -Name GetMaxRunspaces -Value { 2 } + $global:sync = [hashtable]::Synchronized(@{ + FaviconCircuitBreaker = $breaker + FaviconRunspace = $pool + FaviconOperations = [hashtable]::Synchronized(@{}) + FaviconQueue = [System.Collections.Queue]::new() + }) + + 1..5 | ForEach-Object { + $global:sync.FaviconQueue.Enqueue([pscustomobject]@{ + AppKey = "App$_" + Url = "https://example.com/$_" + TargetImage = [Windows.Controls.Image]::new() + Fallback = [Windows.Controls.TextBlock]::new() + }) + } + + Mock Invoke-WinUtilFaviconFetch { + $global:sync.FaviconOperations[$AppKey] = [pscustomobject]@{ AppKey = $AppKey } + } + + Invoke-WinUtilFaviconQueuePump + + $global:sync.FaviconOperations.Count | Should -Be 2 + $global:sync.FaviconQueue.Count | Should -Be 3 + Should -Invoke -CommandName Invoke-WinUtilFaviconFetch -Times 2 -Exactly + } finally { + if ($previousSync) { + Set-Variable -Name sync -Value $previousSync.Value -Scope Global + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + } + } + + It "refills available capacity after a favicon operation completes" { + $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + try { + $breaker = [pscustomobject]@{ IsCancellationRequested = $false } + $breaker | Add-Member -MemberType ScriptMethod -Name ReportFailure -Value { } + $breaker | Add-Member -MemberType ScriptMethod -Name ReportSuccess -Value { } + $pool = [pscustomobject]@{} + $pool | Add-Member -MemberType ScriptMethod -Name GetMaxRunspaces -Value { 1 } + $worker = [pscustomobject]@{ Disposed = $false } + $worker | Add-Member -MemberType ScriptMethod -Name EndInvoke -Value { + param($handle) + return [pscustomobject]@{ Status = "Cancelled"; Bytes = $null } + } + $worker | Add-Member -MemberType ScriptMethod -Name Dispose -Value { $this.Disposed = $true } + $global:sync = [hashtable]::Synchronized(@{ + FaviconCircuitBreaker = $breaker + FaviconRunspace = $pool + FaviconOperations = [hashtable]::Synchronized(@{}) + FaviconQueue = [System.Collections.Queue]::new() + }) + $global:sync.FaviconQueue.Enqueue([pscustomobject]@{ + AppKey = "NextApp" + Url = "https://example.com/next" + TargetImage = [Windows.Controls.Image]::new() + Fallback = [Windows.Controls.TextBlock]::new() + }) + $operation = [pscustomobject]@{ + AppKey = "CurrentApp" + PowerShell = $worker + Handle = $null + Sync = $global:sync + TargetImage = [Windows.Controls.Image]::new() + Fallback = [Windows.Controls.TextBlock]::new() + Bytes = $null + } + $global:sync.FaviconOperations[$operation.AppKey] = $operation + + Mock Invoke-WinUtilFaviconFetch { + $global:sync.FaviconOperations[$AppKey] = [pscustomobject]@{ AppKey = $AppKey } + } + + Complete-WinUtilFaviconFetch -Operation $operation + + $worker.Disposed | Should -BeTrue + $global:sync.FaviconQueue.Count | Should -Be 0 + $global:sync.FaviconOperations.ContainsKey("NextApp") | Should -BeTrue + Should -Invoke -CommandName Invoke-WinUtilFaviconFetch -Times 1 -Exactly + } finally { + if ($previousSync) { + Set-Variable -Name sync -Value $previousSync.Value -Scope Global + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + } + } + + It "drops pending requests when the circuit breaker is open" { + $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + try { + $pool = [pscustomobject]@{} + $pool | Add-Member -MemberType ScriptMethod -Name GetMaxRunspaces -Value { 2 } + $global:sync = [hashtable]::Synchronized(@{ + FaviconCircuitBreaker = [pscustomobject]@{ IsCancellationRequested = $true } + FaviconRunspace = $pool + FaviconOperations = [hashtable]::Synchronized(@{}) + FaviconQueue = [System.Collections.Queue]::new() + }) + $global:sync.FaviconQueue.Enqueue([pscustomobject]@{ AppKey = "PendingApp" }) + Mock Invoke-WinUtilFaviconFetch { } + + Invoke-WinUtilFaviconQueuePump + + $global:sync.FaviconQueue.Count | Should -Be 0 + Should -Invoke -CommandName Invoke-WinUtilFaviconFetch -Times 0 -Exactly + } finally { + if ($previousSync) { + Set-Variable -Name sync -Value $previousSync.Value -Scope Global + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + } + } + + It "stops pending work when request submission fails" { + $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + try { + $breaker = [pscustomobject]@{ IsCancellationRequested = $false } + $breaker | Add-Member -MemberType ScriptMethod -Name Cancel -Value { $this.IsCancellationRequested = $true } + $pool = [pscustomobject]@{} + $pool | Add-Member -MemberType ScriptMethod -Name GetMaxRunspaces -Value { 2 } + $global:sync = [hashtable]::Synchronized(@{ + FaviconCircuitBreaker = $breaker + FaviconRunspace = $pool + FaviconOperations = [hashtable]::Synchronized(@{}) + FaviconQueue = [System.Collections.Queue]::new() + }) + 1..2 | ForEach-Object { + $global:sync.FaviconQueue.Enqueue([pscustomobject]@{ + AppKey = "App$_" + Url = "https://example.com/$_" + TargetImage = [Windows.Controls.Image]::new() + Fallback = [Windows.Controls.TextBlock]::new() + }) + } + Mock Invoke-WinUtilFaviconFetch { throw "submission failed" } + + { Invoke-WinUtilFaviconQueuePump } | Should -Not -Throw + + $breaker.IsCancellationRequested | Should -BeTrue + $global:sync.FaviconQueue.Count | Should -Be 0 + Should -Invoke -CommandName Invoke-WinUtilFaviconFetch -Times 1 -Exactly + } finally { + if ($previousSync) { + Set-Variable -Name sync -Value $previousSync.Value -Scope Global + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + } + } + + It "clears pending requests when favicon setup fails" { + $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + try { + $global:sync = [hashtable]::Synchronized(@{ FaviconQueue = [System.Collections.Queue]::new() }) + $global:sync.FaviconQueue.Enqueue([pscustomobject]@{ AppKey = "PendingApp" }) + Mock Initialize-WinUtilFaviconCircuitBreaker { throw "setup failed" } + Mock Close-WinUtilFaviconRunspacePool { } + + { Start-WinUtilFaviconLoading } | Should -Not -Throw + + $global:sync.FaviconQueue.Count | Should -Be 0 + Should -Invoke -CommandName Close-WinUtilFaviconRunspacePool -Times 1 -Exactly + } finally { + if ($previousSync) { + Set-Variable -Name sync -Value $previousSync.Value -Scope Global + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + } + } + It "applies results and fallback state through the WPF dispatcher" { $fetchScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilFaviconFetch.ps1") -Raw @@ -221,4 +405,22 @@ Describe "WinUtil favicon loading" { $closeScript | Should -Match '\.Dispose\(\)' $closeScript | Should -Match 'FaviconRunspace\.Close\(\)' } + + It "removes pending favicon requests during shutdown" { + $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + try { + $global:sync = [hashtable]::Synchronized(@{ FaviconQueue = [System.Collections.Queue]::new() }) + $global:sync.FaviconQueue.Enqueue([pscustomobject]@{ AppKey = "PendingApp" }) + + Close-WinUtilFaviconRunspacePool + + $global:sync.ContainsKey("FaviconQueue") | Should -BeFalse + } finally { + if ($previousSync) { + Set-Variable -Name sync -Value $previousSync.Value -Scope Global + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + } + } } diff --git a/pester/install-rendering.Tests.ps1 b/pester/install-rendering.Tests.ps1 index 33398243e8..f4d569e1c8 100644 --- a/pester/install-rendering.Tests.ps1 +++ b/pester/install-rendering.Tests.ps1 @@ -11,6 +11,7 @@ Describe "Install app rendering startup contract" { $categoryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallCategoryAppList.ps1") -Raw $categoryScript | Should -Match '\$sync\.InstallAppRenderQueue = \[System\.Collections\.Queue\]::new\(\)' + $categoryScript | Should -Match '\$sync\.FaviconQueue = \[System\.Collections\.Queue\]::new\(\)' $categoryScript | Should -Match 'Start-WinUtilInstallAppRendering' $categoryScript | Should -Match 'Pre-group apps by category before creating WPF controls' } @@ -44,6 +45,7 @@ Describe "Install app rendering startup contract" { $previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue $previousInitializeAppEntry = Get-Item -Path Function:\Initialize-InstallAppEntry -ErrorAction SilentlyContinue $previousSearch = Get-Item -Path Function:\Find-AppsByNameOrDescription -ErrorAction SilentlyContinue + $previousStartFaviconLoading = Get-Item -Path Function:\Start-WinUtilFaviconLoading -ErrorAction SilentlyContinue $errorCountBefore = $global:Error.Count try { @@ -52,12 +54,14 @@ Describe "Install app rendering startup contract" { $global:sync.SearchBar = [pscustomobject]@{ Text = "" } $global:sync.Form = [pscustomobject]@{ Dispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher } $global:sync.InstallAppRenderQueue = [System.Collections.Queue]::new() + $global:sync.FaviconQueue = [System.Collections.Queue]::new() + $global:sync.FaviconQueue.Enqueue([pscustomobject]@{ AppKey = "QueuedFavicon" }) - $renderedApps = [System.Collections.Generic.List[string]]::new() + $renderSequence = [System.Collections.Generic.List[string]]::new() function global:Initialize-InstallAppEntry { param($TargetElement, $AppKey) - $renderedApps.Add($AppKey) + $renderSequence.Add($AppKey) return "entry:$AppKey" } @@ -66,6 +70,10 @@ Describe "Install app rendering startup contract" { throw "Search should not run for an empty search box in this test." } + function global:Start-WinUtilFaviconLoading { + $renderSequence.Add("Favicons") + } + $global:sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{ TargetElement = [pscustomobject]@{}; AppKeys = @("AppA", "AppB") }) $global:sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{ TargetElement = [pscustomobject]@{}; AppKeys = @("AppC") }) @@ -79,7 +87,7 @@ Describe "Install app rendering startup contract" { param($eventSender) $timer = [System.Windows.Threading.DispatcherTimer]$eventSender - if ($global:sync.InstallAppEntriesRendered -or $timeout.Elapsed.TotalSeconds -gt 5) { + if ($renderSequence -contains "Favicons" -or $timeout.Elapsed.TotalSeconds -gt 5) { $timer.Stop() $frame.Continue = $false } @@ -90,7 +98,7 @@ Describe "Install app rendering startup contract" { $global:sync.InstallAppEntriesRendered | Should -BeTrue $global:sync.InstallAppRenderQueue.Count | Should -Be 0 - @($renderedApps) | Should -Be @("AppA", "AppB", "AppC") + @($renderSequence) | Should -Be @("AppA", "AppB", "AppC", "Favicons") $global:Error.Count | Should -Be $errorCountBefore } finally { if ($previousSync) { @@ -101,7 +109,8 @@ Describe "Install app rendering startup contract" { foreach ($functionBackup in @( @{ Name = "Initialize-InstallAppEntry"; Backup = $previousInitializeAppEntry }, - @{ Name = "Find-AppsByNameOrDescription"; Backup = $previousSearch } + @{ Name = "Find-AppsByNameOrDescription"; Backup = $previousSearch }, + @{ Name = "Start-WinUtilFaviconLoading"; Backup = $previousStartFaviconLoading } )) { if ($functionBackup.Backup) { Set-Item -Path "Function:\$($functionBackup.Name)" -Value $functionBackup.Backup.ScriptBlock diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 index 6663bb680d..e976af5184 100644 --- a/pester/xaml.Tests.ps1 +++ b/pester/xaml.Tests.ps1 @@ -460,6 +460,7 @@ Describe "XAML and sync wiring" { "ToggleStatusCache", "InstallAppRenderQueue", "InstallAppEntriesRendered", + "FaviconQueue", "FaviconOperations", "FaviconCircuitBreaker", "FaviconRunspace", From d6f527100557dad42f847453f2bac4e5ba476616 Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:15:20 -0400 Subject: [PATCH 8/9] refactor: update favicon runspace pool logic for improved concurrency after benchmarking --- .../Initialize-WinUtilFaviconRunspacePool.ps1 | 12 ++++-------- pester/favicon-loading.Tests.ps1 | 8 ++------ 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 b/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 index 8a475c7b10..0c8ef20b2b 100644 --- a/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 +++ b/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 @@ -3,9 +3,9 @@ function Initialize-WinUtilFaviconRunspacePool { .SYNOPSIS Creates or returns the dedicated runspace pool used for favicon downloads. .DESCRIPTION - Uses half the available logical processors while keeping concurrency between - two and eight workers so favicon requests remain responsive without creating - an excessive burst of connections. + Uses the machine's available logical processor count, with a minimum of one + worker. The pool remains dedicated to favicon downloads so this work stays + isolated from other WinUtil runspaces. #> if ($sync.FaviconRunspace -and $sync.FaviconRunspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) { return $sync.FaviconRunspace @@ -26,11 +26,7 @@ function Initialize-WinUtilFaviconRunspacePool { } } - $minimumWorkers = 2 - $maximumWorkers = 8 - $halfProcessors = [Math]::Floor([Environment]::ProcessorCount / 2) - $maxThreads = [Math]::Max($halfProcessors, $minimumWorkers) - $maxThreads = [Math]::Min($maxThreads, $maximumWorkers) + $maxthreads = [Math]::Max(1, [int]$env:NUMBER_OF_PROCESSORS) $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() $sync.FaviconRunspace = [runspacefactory]::CreateRunspacePool( diff --git a/pester/favicon-loading.Tests.ps1 b/pester/favicon-loading.Tests.ps1 index 4a015504e5..4773084a31 100644 --- a/pester/favicon-loading.Tests.ps1 +++ b/pester/favicon-loading.Tests.ps1 @@ -17,14 +17,10 @@ Describe "WinUtil favicon loading" { Get-WinUtilFaviconUrl -Link " " | Should -BeNullOrEmpty } - It "uses a dedicated pool capped between two and eight workers" { + It "uses a dedicated pool sized to the available logical processors" { $poolScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilFaviconRunspacePool.ps1") -Raw - $poolScript | Should -Match '\[Environment\]::ProcessorCount / 2' - $poolScript | Should -Match '\$minimumWorkers = 2' - $poolScript | Should -Match '\$maximumWorkers = 8' - $poolScript | Should -Match '\[Math\]::Max\(\$halfProcessors, \$minimumWorkers\)' - $poolScript | Should -Match '\[Math\]::Min\(\$maxThreads, \$maximumWorkers\)' + $poolScript | Should -Match '\$maxthreads = \[Math\]::Max\(1, \[int\]\$env:NUMBER_OF_PROCESSORS\)' $poolScript | Should -Match 'CreateRunspacePool' } From a0721beb01c522d74118e1b7f5087415d129d000 Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:20:54 -0400 Subject: [PATCH 9/9] refactor: use runtime processor count for favicon workers --- functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 | 2 +- pester/favicon-loading.Tests.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 b/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 index 0c8ef20b2b..52fb9a99f6 100644 --- a/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 +++ b/functions/private/Initialize-WinUtilFaviconRunspacePool.ps1 @@ -26,7 +26,7 @@ function Initialize-WinUtilFaviconRunspacePool { } } - $maxthreads = [Math]::Max(1, [int]$env:NUMBER_OF_PROCESSORS) + $maxThreads = [Environment]::ProcessorCount $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() $sync.FaviconRunspace = [runspacefactory]::CreateRunspacePool( diff --git a/pester/favicon-loading.Tests.ps1 b/pester/favicon-loading.Tests.ps1 index 4773084a31..32c0dafc5d 100644 --- a/pester/favicon-loading.Tests.ps1 +++ b/pester/favicon-loading.Tests.ps1 @@ -20,7 +20,7 @@ Describe "WinUtil favicon loading" { It "uses a dedicated pool sized to the available logical processors" { $poolScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilFaviconRunspacePool.ps1") -Raw - $poolScript | Should -Match '\$maxthreads = \[Math\]::Max\(1, \[int\]\$env:NUMBER_OF_PROCESSORS\)' + $poolScript | Should -Match '\$maxThreads = \[Environment\]::ProcessorCount' $poolScript | Should -Match 'CreateRunspacePool' }