From da299d8a0374eaa80ead987d4698ee4e908a3065 Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Thu, 17 Jul 2025 18:24:44 -0700 Subject: [PATCH] Fix race condition in parallel UI status updates Prevents stale, intermediate status messages from overwriting the final status of a completed task in the UI. A set of completed task identifiers is now maintained. Any incoming intermediate status updates for tasks that are already marked as complete are ignored. The job completion logic is also refactored for better robustness and clarity across different job-end states (failed, completed with data, completed without data). --- .../FFU.Common/FFU.Common.Parallel.psm1 | 119 ++++++++++-------- .../FFUUI.Core/FFUUI.Core.Applications.psm1 | 11 +- .../FFUUI.Core/FFUUI.Core.Shared.psm1 | 11 +- 3 files changed, 77 insertions(+), 64 deletions(-) diff --git a/FFUDevelopment/FFU.Common/FFU.Common.Parallel.psm1 b/FFUDevelopment/FFU.Common/FFU.Common.Parallel.psm1 index c62ce96..bb76e1b 100644 --- a/FFUDevelopment/FFU.Common/FFU.Common.Parallel.psm1 +++ b/FFUDevelopment/FFU.Common/FFU.Common.Parallel.psm1 @@ -36,6 +36,7 @@ function Invoke-ParallelProcessing { $jobs = @() $totalItems = $ItemsToProcess.Count $processedCount = 0 + $completedIdentifiers = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) # Create a thread-safe queue for intermediate progress updates $progressQueue = New-Object System.Collections.Concurrent.ConcurrentQueue[hashtable] @@ -319,17 +320,24 @@ function Invoke-ParallelProcessing { # Continue while jobs are running OR queue has messages # 1. Process intermediate status updates from the queue - $statusUpdate = $null + $statusUpdate = $null while ($progressQueue.TryDequeue([ref]$statusUpdate)) { if ($null -ne $statusUpdate) { + WriteLog "Dequeued progress update: $($statusUpdate | ConvertTo-Json -Compress)" $intermediateIdentifier = $statusUpdate.Identifier + # If this item has already been marked as complete, skip this stale intermediate update + if ($completedIdentifiers.Contains($intermediateIdentifier)) { + WriteLog "Skipping stale intermediate status for already completed item: $intermediateIdentifier" + continue + } $intermediateStatus = $statusUpdate.Status if ($isUiMode) { # Use the new $isUiMode flag # Update the UI with the intermediate status try { - $WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { - Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $intermediateIdentifier -StatusProperty $StatusProperty -StatusValue $intermediateStatus + WriteLog "Dispatching INTERMEDIATE status for '$intermediateIdentifier': '$intermediateStatus'" + $WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { + Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $intermediateIdentifier -StatusProperty $StatusProperty -StatusValue $intermediateStatus }) } catch { @@ -348,79 +356,80 @@ function Invoke-ParallelProcessing { if ($completedJobs) { foreach ($completedJob in $completedJobs) { - $finalIdentifier = "UnknownJob" # Placeholder if we can't get result - $finalStatus = "$ErrorStatusPrefix Job $($completedJob.Id) ended unexpectedly" - $finalResultCode = 1 # Assume error - + $jobHandled = $false if ($completedJob.State -eq 'Failed') { + $jobHandled = $true + $finalIdentifier = "UnknownJob" # Placeholder WriteLog "Job $($completedJob.Id) failed: $($completedJob.Error)" - # Try to get identifier from job name if possible (less reliable) - # $finalIdentifier = ... logic to parse job name or map ID ... $finalStatus = "$ErrorStatusPrefix Job Failed" - $processedCount++ # Count failed job as processed + $finalResultCode = 1 + $processedCount++ + + # --- DISPATCH FOR FAILED JOB --- + $completedIdentifiers.Add($finalIdentifier) | Out-Null + if ($isUiMode) { + try { + WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'" + $WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus }) + $WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" }) + } + catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" } + } + else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" } } elseif ($completedJob.HasMoreData) { - # Receive final results specifically from the completed job + $jobHandled = $true $jobResults = $completedJob | Receive-Job foreach ($result in $jobResults) { - # Should only be one result per job in this setup + WriteLog "Received FINAL job result: $($result | ConvertTo-Json -Compress -Depth 3)" if ($null -ne $result -and $result -is [hashtable] -and $result.ContainsKey('Identifier')) { $finalIdentifier = $result.Identifier - $status = $result.Status # This is the FINAL status returned by the task + $status = $result.Status $finalResultCode = $result.ResultCode - - # Determine final status text based on the result code - if ($finalResultCode -eq 0) { - # Assuming 0 means success - # Use the specific status returned by the successful job - # This handles cases like "Already downloaded" correctly - $finalStatus = $status - } - else { - $finalStatus = "$($ErrorStatusPrefix)$($status)" # Use status from result for error message - } + $finalStatus = if ($finalResultCode -eq 0) { $status } else { "$($ErrorStatusPrefix)$($status)" } $processedCount++ } else { + $finalIdentifier = "UnknownResult" WriteLog "Warning: Received unexpected final job result format: $($result | Out-String)" $finalStatus = "$ErrorStatusPrefix Invalid Result Format" - $processedCount++ # Count as processed to avoid loop issues + $finalResultCode = 1 + $processedCount++ } - # Add the received result (even if format was unexpected, for logging) - if ($null -ne $result) { $resultsCollection.Add($result) } + if ($null -ne $result) { $resultsCollection.Add($result) } + + # --- DISPATCH PER RESULT --- + $completedIdentifiers.Add($finalIdentifier) | Out-Null + if ($isUiMode) { + try { + WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'" + $WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus }) + $WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" }) + } + catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" } } - } - else { - # Job completed but had no data - if ($completedJob.State -ne 'Failed') { - WriteLog "Job $($completedJob.Id) completed with state '$($completedJob.State)' but had no data." - # $finalIdentifier = ... logic to parse job name or map ID ... - $finalStatus = "$ErrorStatusPrefix No Result Data" - $processedCount++ + else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" } } - # If it was 'Failed', it was handled above } + + if (-not $jobHandled) { # Catches 'Completed' with no data + $finalIdentifier = "UnknownJob" + WriteLog "Job $($completedJob.Id) completed with state '$($completedJob.State)' but had no data." + $finalStatus = "$ErrorStatusPrefix No Result Data" + $finalResultCode = 1 + $processedCount++ - # Update the specific item in the ListView with its FINAL status - if ($isUiMode) { - # Use the new $isUiMode flag - try { - $WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { - Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus - }) + # --- DISPATCH FOR NO-DATA JOB --- + $completedIdentifiers.Add($finalIdentifier) | Out-Null + if ($isUiMode) { + try { + WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'" + $WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus }) + $WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" }) + } + catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" } } - catch { - WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" - } - - # Update overall progress after processing a job's results - $WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { - Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" - }) - } - else { - # Log final status if not in UI mode - WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" + else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" } } # Remove the completed/failed job from the list and clean it up diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Applications.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Applications.psm1 index db19b7d..2a307a1 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Applications.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Applications.psm1 @@ -234,7 +234,7 @@ function Invoke-CopyBYOApps { $allAppsWithSource = $State.Controls.lstApplications.Items | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Source) } if (-not $allAppsWithSource) { - [System.Windows.MessageBox]::Show("UserAppList.json has been updated. No applications with a source path were found to copy.", "Copy BYO Apps", "OK", "Information") + [System.Windows.MessageBox]::Show("No applications with a source path were found to copy.", "Copy BYO Apps", "OK", "Information") return } @@ -341,7 +341,7 @@ function Start-CopyBYOApplicationTask { } if (-not (Test-Path -Path $sourcePath -PathType Container)) { - $status = "Error: Source path not found" + $status = "Source path not found" Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status WriteLog "Copy error for $($appName): Source path '$sourcePath' not found." return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success } @@ -381,12 +381,7 @@ function Start-CopyBYOApplicationTask { # Enqueue error status Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status } - - # Enqueue final success status if applicable - if ($success) { - Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status - } - + # Return the final status return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success } } diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1 index f8b21b0..f5baebd 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1 @@ -113,7 +113,16 @@ function Update-ListViewItemStatus { if ($WindowObject -is [System.Windows.Window] -and $ListView -is [System.Windows.Controls.ListView]) { # Directly update UI elements as this function is now called on the UI thread try { - $itemToUpdate = $ListView.ItemsSource | Where-Object { $_.$IdentifierProperty -eq $IdentifierValue } | Select-Object -First 1 + # Determine which collection to search: ItemsSource (preferred) or Items. + $collectionToSearch = $null + if ($null -ne $ListView.ItemsSource) { + $collectionToSearch = $ListView.ItemsSource + } + else { + $collectionToSearch = $ListView.Items + } + + $itemToUpdate = $collectionToSearch | Where-Object { $_.$IdentifierProperty -eq $IdentifierValue } | Select-Object -First 1 if ($null -ne $itemToUpdate) { $itemToUpdate.$StatusProperty = $StatusValue $ListView.Items.Refresh() # Refresh the view to show the change