From 53741632a4b442d5586f405edc8a2992dd976e1a Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:05:36 -0800 Subject: [PATCH] Prevents JSON corruption during parallel app updates Adds cross-process locking and atomic writes to avoid race conditions and partial writes when multiple runspaces update the app command metadata in parallel. Improves resilience by backing up and rebuilding when existing JSON is malformed, ensuring the build continues safely. --- .../FFU.Common/FFU.Common.Winget.psm1 | 219 +++++++++++++----- 1 file changed, 167 insertions(+), 52 deletions(-) diff --git a/FFUDevelopment/FFU.Common/FFU.Common.Winget.psm1 b/FFUDevelopment/FFU.Common/FFU.Common.Winget.psm1 index dff277c..d38918a 100644 --- a/FFUDevelopment/FFU.Common/FFU.Common.Winget.psm1 +++ b/FFUDevelopment/FFU.Common/FFU.Common.Winget.psm1 @@ -792,39 +792,44 @@ function Get-Apps { if ($overrideMap.Count -gt 0) { $winGetWin32Path = Join-Path -Path $OrchestrationPath -ChildPath 'WinGetWin32Apps.json' if (Test-Path -Path $winGetWin32Path) { - [array]$appsDataUpdated = Get-Content -Path $winGetWin32Path -Raw | ConvertFrom-Json - $changed = $false - foreach ($entry in $appsDataUpdated) { - if ($overrideMap.ContainsKey($entry.Name)) { - $ov = $overrideMap[$entry.Name] - if ($ov.CommandLine) { - WriteLog "Override (AppList.json) CommandLine for $($entry.Name)" - $entry.CommandLine = $ov.CommandLine - $changed = $true - } - if ($ov.Arguments) { - WriteLog "Override (AppList.json) Arguments for $($entry.Name)" - $entry.Arguments = $ov.Arguments - $changed = $true - } - if ($ov.ContainsKey('AdditionalExitCodes') -and $null -ne $ov.AdditionalExitCodes) { - WriteLog "Override (AppList.json) AdditionalExitCodes for $($entry.Name)" - $entry | Add-Member -NotePropertyName AdditionalExitCodes -NotePropertyValue $ov.AdditionalExitCodes -Force - $changed = $true - } - if ($ov.ContainsKey('IgnoreNonZeroExitCodes') -and $null -ne $ov.IgnoreNonZeroExitCodes) { - WriteLog "Override (AppList.json) IgnoreNonZeroExitCodes for $($entry.Name)" - $entry | Add-Member -NotePropertyName IgnoreNonZeroExitCodes -NotePropertyValue ([bool]$ov.IgnoreNonZeroExitCodes) -Force - $changed = $true + # Lock WinGetWin32Apps.json during override writes to avoid any unexpected concurrent access + $mutexName = Get-WinGetWin32AppsJsonMutexName -WinGetWin32AppsJsonPath $winGetWin32Path + Invoke-WithNamedMutex -MutexName $mutexName -TimeoutSeconds 60 -ScriptBlock { + [array]$appsDataUpdated = Get-Content -Path $winGetWin32Path -Raw | ConvertFrom-Json + $changed = $false + foreach ($entry in $appsDataUpdated) { + if ($overrideMap.ContainsKey($entry.Name)) { + $ov = $overrideMap[$entry.Name] + if ($ov.CommandLine) { + WriteLog "Override (AppList.json) CommandLine for $($entry.Name)" + $entry.CommandLine = $ov.CommandLine + $changed = $true + } + if ($ov.Arguments) { + WriteLog "Override (AppList.json) Arguments for $($entry.Name)" + $entry.Arguments = $ov.Arguments + $changed = $true + } + if ($ov.ContainsKey('AdditionalExitCodes') -and $null -ne $ov.AdditionalExitCodes) { + WriteLog "Override (AppList.json) AdditionalExitCodes for $($entry.Name)" + $entry | Add-Member -NotePropertyName AdditionalExitCodes -NotePropertyValue $ov.AdditionalExitCodes -Force + $changed = $true + } + if ($ov.ContainsKey('IgnoreNonZeroExitCodes') -and $null -ne $ov.IgnoreNonZeroExitCodes) { + WriteLog "Override (AppList.json) IgnoreNonZeroExitCodes for $($entry.Name)" + $entry | Add-Member -NotePropertyName IgnoreNonZeroExitCodes -NotePropertyValue ([bool]$ov.IgnoreNonZeroExitCodes) -Force + $changed = $true + } } } - } - if ($changed) { - $appsDataUpdated | ConvertTo-Json -Depth 10 | Set-Content -Path $winGetWin32Path - WriteLog "Applied AppList.json command overrides to WinGetWin32Apps.json" - } - else { - WriteLog "No matching apps required command overrides." + if ($changed) { + $jsonText = $appsDataUpdated | ConvertTo-Json -Depth 10 + Set-FileContentAtomic -Path $winGetWin32Path -Content $jsonText + WriteLog "Applied AppList.json command overrides to WinGetWin32Apps.json" + } + else { + WriteLog "No matching apps required command overrides." + } } } else { @@ -909,6 +914,96 @@ function Confirm-WinGetInstallation { WriteLog "Installed WinGet version: $wingetVersion" } } +# -------------------------------------------------------------------------- +# SECTION: WinGetWin32Apps.json File Locking Helpers +# -------------------------------------------------------------------------- +function Get-WinGetWin32AppsJsonMutexName { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$WinGetWin32AppsJsonPath + ) + + # Create a stable, safe mutex name based on the full file path + # This prevents cross-runspace/cross-process corruption when multiple apps write the same JSON. + $normalizedPath = $WinGetWin32AppsJsonPath.ToLowerInvariant() + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalizedPath) + $hashBytes = $sha256.ComputeHash($bytes) + } + finally { + $sha256.Dispose() + } + + $hash = -join ($hashBytes | ForEach-Object { $_.ToString('x2') }) + return "WinGetWin32AppsJsonLock_$hash" +} + +function Invoke-WithNamedMutex { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$MutexName, + [Parameter(Mandatory = $true)] + [scriptblock]$ScriptBlock, + [int]$TimeoutSeconds = 60 + ) + + # Use a named mutex so all parallel runspaces serialize file access + $mutex = New-Object System.Threading.Mutex($false, $MutexName) + $lockTaken = $false + + try { + $lockTaken = $mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds)) + if (-not $lockTaken) { + throw "Timed out waiting for mutex '$MutexName' after $TimeoutSeconds seconds." + } + + & $ScriptBlock + } + finally { + if ($lockTaken) { + try { + $mutex.ReleaseMutex() | Out-Null + } + catch { + # Best-effort release; ignore release failures + } + } + $mutex.Dispose() + } +} + +function Set-FileContentAtomic { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Content + ) + + # Write to a unique temp file in the same directory and then rename into place + # to reduce the chance of partial writes. + $parentPath = Split-Path -Path $Path -Parent + if (-not (Test-Path -Path $parentPath -PathType Container)) { + New-Item -Path $parentPath -ItemType Directory -Force | Out-Null + } + + $tempPath = "$Path.$([guid]::NewGuid().ToString('N')).tmp" + Set-Content -Path $tempPath -Value $Content -Encoding UTF8 + + try { + # PowerShell 7+ (.NET) supports overwrite via File.Move overload + [System.IO.File]::Move($tempPath, $Path, $true) + } + catch { + # Fallback for environments where overwrite overload is unavailable + Move-Item -Path $tempPath -Destination $Path -Force + } +} + function Add-Win32SilentInstallCommand { param ( [string]$AppFolder, @@ -1061,30 +1156,50 @@ function Add-Win32SilentInstallCommand { # Path to the JSON file $wingetWin32AppsJson = "$OrchestrationPath\WinGetWin32Apps.json" - # Initialize or load existing JSON data - if (Test-Path -Path $wingetWin32AppsJson) { - [array]$appsData = Get-Content -Path $wingetWin32AppsJson -Raw | ConvertFrom-Json - - # Get highest priority value - if ($appsData.Count -gt 0) { - $highestPriority = $appsData.Count + 1 - } - } - else { + # Serialize access to WinGetWin32Apps.json to prevent corruption when multiple apps are processed in parallel + $mutexName = Get-WinGetWin32AppsJsonMutexName -WinGetWin32AppsJsonPath $wingetWin32AppsJson + Invoke-WithNamedMutex -MutexName $mutexName -TimeoutSeconds 60 -ScriptBlock { + # Initialize or load existing JSON data $appsData = @() - $highestPriority = 1 - } + if (Test-Path -Path $wingetWin32AppsJson) { + try { + [array]$appsData = Get-Content -Path $wingetWin32AppsJson -Raw | ConvertFrom-Json + if ($null -eq $appsData) { + $appsData = @() + } + } + catch { + # Backup the corrupted file so the build can continue + $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' + $backupPath = "$wingetWin32AppsJson.corrupt.$timestamp" + try { + Copy-Item -Path $wingetWin32AppsJson -Destination $backupPath -Force + WriteLog "WinGetWin32Apps.json could not be parsed. Backed up corrupt file to '$backupPath' and rebuilding." + } + catch { + WriteLog "WinGetWin32Apps.json could not be parsed and backup failed: $($_.Exception.Message). Rebuilding anyway." + } - # Create new app entry - $newApp = [PSCustomObject]@{ - Priority = $highestPriority - Name = if (-not [string]::IsNullOrEmpty($SubFolder)) { "$appName ($SubFolder)" } else { $appName } - CommandLine = $silentInstallCommand - Arguments = $silentInstallSwitch - } + $appsData = @() + } + } - $appsData += $newApp - $appsData | ConvertTo-Json -Depth 10 | Set-Content -Path $wingetWin32AppsJson + # Calculate next priority (always set, even if the file exists but is empty) + $highestPriority = if ($appsData.Count -gt 0) { $appsData.Count + 1 } else { 1 } + + # Create new app entry + $newApp = [PSCustomObject]@{ + Priority = $highestPriority + Name = if (-not [string]::IsNullOrEmpty($SubFolder)) { "$appName ($SubFolder)" } else { $appName } + CommandLine = $silentInstallCommand + Arguments = $silentInstallSwitch + } + + # Write the updated JSON file using a temp+rename to reduce partial-write risk + $appsData += $newApp + $jsonText = $appsData | ConvertTo-Json -Depth 10 + Set-FileContentAtomic -Path $wingetWin32AppsJson -Content $jsonText + } WriteLog "Added $($newApp.Name) to WinGetWin32Apps.json with priority $highestPriority"