mirror of
https://github.com/rbalsleyMSFT/FFU.git
synced 2026-06-14 02:09:35 -06:00
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.
This commit is contained in:
@@ -792,39 +792,44 @@ function Get-Apps {
|
|||||||
if ($overrideMap.Count -gt 0) {
|
if ($overrideMap.Count -gt 0) {
|
||||||
$winGetWin32Path = Join-Path -Path $OrchestrationPath -ChildPath 'WinGetWin32Apps.json'
|
$winGetWin32Path = Join-Path -Path $OrchestrationPath -ChildPath 'WinGetWin32Apps.json'
|
||||||
if (Test-Path -Path $winGetWin32Path) {
|
if (Test-Path -Path $winGetWin32Path) {
|
||||||
[array]$appsDataUpdated = Get-Content -Path $winGetWin32Path -Raw | ConvertFrom-Json
|
# Lock WinGetWin32Apps.json during override writes to avoid any unexpected concurrent access
|
||||||
$changed = $false
|
$mutexName = Get-WinGetWin32AppsJsonMutexName -WinGetWin32AppsJsonPath $winGetWin32Path
|
||||||
foreach ($entry in $appsDataUpdated) {
|
Invoke-WithNamedMutex -MutexName $mutexName -TimeoutSeconds 60 -ScriptBlock {
|
||||||
if ($overrideMap.ContainsKey($entry.Name)) {
|
[array]$appsDataUpdated = Get-Content -Path $winGetWin32Path -Raw | ConvertFrom-Json
|
||||||
$ov = $overrideMap[$entry.Name]
|
$changed = $false
|
||||||
if ($ov.CommandLine) {
|
foreach ($entry in $appsDataUpdated) {
|
||||||
WriteLog "Override (AppList.json) CommandLine for $($entry.Name)"
|
if ($overrideMap.ContainsKey($entry.Name)) {
|
||||||
$entry.CommandLine = $ov.CommandLine
|
$ov = $overrideMap[$entry.Name]
|
||||||
$changed = $true
|
if ($ov.CommandLine) {
|
||||||
}
|
WriteLog "Override (AppList.json) CommandLine for $($entry.Name)"
|
||||||
if ($ov.Arguments) {
|
$entry.CommandLine = $ov.CommandLine
|
||||||
WriteLog "Override (AppList.json) Arguments for $($entry.Name)"
|
$changed = $true
|
||||||
$entry.Arguments = $ov.Arguments
|
}
|
||||||
$changed = $true
|
if ($ov.Arguments) {
|
||||||
}
|
WriteLog "Override (AppList.json) Arguments for $($entry.Name)"
|
||||||
if ($ov.ContainsKey('AdditionalExitCodes') -and $null -ne $ov.AdditionalExitCodes) {
|
$entry.Arguments = $ov.Arguments
|
||||||
WriteLog "Override (AppList.json) AdditionalExitCodes for $($entry.Name)"
|
$changed = $true
|
||||||
$entry | Add-Member -NotePropertyName AdditionalExitCodes -NotePropertyValue $ov.AdditionalExitCodes -Force
|
}
|
||||||
$changed = $true
|
if ($ov.ContainsKey('AdditionalExitCodes') -and $null -ne $ov.AdditionalExitCodes) {
|
||||||
}
|
WriteLog "Override (AppList.json) AdditionalExitCodes for $($entry.Name)"
|
||||||
if ($ov.ContainsKey('IgnoreNonZeroExitCodes') -and $null -ne $ov.IgnoreNonZeroExitCodes) {
|
$entry | Add-Member -NotePropertyName AdditionalExitCodes -NotePropertyValue $ov.AdditionalExitCodes -Force
|
||||||
WriteLog "Override (AppList.json) IgnoreNonZeroExitCodes for $($entry.Name)"
|
$changed = $true
|
||||||
$entry | Add-Member -NotePropertyName IgnoreNonZeroExitCodes -NotePropertyValue ([bool]$ov.IgnoreNonZeroExitCodes) -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) {
|
||||||
if ($changed) {
|
$jsonText = $appsDataUpdated | ConvertTo-Json -Depth 10
|
||||||
$appsDataUpdated | ConvertTo-Json -Depth 10 | Set-Content -Path $winGetWin32Path
|
Set-FileContentAtomic -Path $winGetWin32Path -Content $jsonText
|
||||||
WriteLog "Applied AppList.json command overrides to WinGetWin32Apps.json"
|
WriteLog "Applied AppList.json command overrides to WinGetWin32Apps.json"
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
WriteLog "No matching apps required command overrides."
|
WriteLog "No matching apps required command overrides."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -909,6 +914,96 @@ function Confirm-WinGetInstallation {
|
|||||||
WriteLog "Installed WinGet version: $wingetVersion"
|
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 {
|
function Add-Win32SilentInstallCommand {
|
||||||
param (
|
param (
|
||||||
[string]$AppFolder,
|
[string]$AppFolder,
|
||||||
@@ -1061,30 +1156,50 @@ function Add-Win32SilentInstallCommand {
|
|||||||
# Path to the JSON file
|
# Path to the JSON file
|
||||||
$wingetWin32AppsJson = "$OrchestrationPath\WinGetWin32Apps.json"
|
$wingetWin32AppsJson = "$OrchestrationPath\WinGetWin32Apps.json"
|
||||||
|
|
||||||
# Initialize or load existing JSON data
|
# Serialize access to WinGetWin32Apps.json to prevent corruption when multiple apps are processed in parallel
|
||||||
if (Test-Path -Path $wingetWin32AppsJson) {
|
$mutexName = Get-WinGetWin32AppsJsonMutexName -WinGetWin32AppsJsonPath $wingetWin32AppsJson
|
||||||
[array]$appsData = Get-Content -Path $wingetWin32AppsJson -Raw | ConvertFrom-Json
|
Invoke-WithNamedMutex -MutexName $mutexName -TimeoutSeconds 60 -ScriptBlock {
|
||||||
|
# Initialize or load existing JSON data
|
||||||
# Get highest priority value
|
|
||||||
if ($appsData.Count -gt 0) {
|
|
||||||
$highestPriority = $appsData.Count + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$appsData = @()
|
$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
|
$appsData = @()
|
||||||
$newApp = [PSCustomObject]@{
|
}
|
||||||
Priority = $highestPriority
|
}
|
||||||
Name = if (-not [string]::IsNullOrEmpty($SubFolder)) { "$appName ($SubFolder)" } else { $appName }
|
|
||||||
CommandLine = $silentInstallCommand
|
|
||||||
Arguments = $silentInstallSwitch
|
|
||||||
}
|
|
||||||
|
|
||||||
$appsData += $newApp
|
# Calculate next priority (always set, even if the file exists but is empty)
|
||||||
$appsData | ConvertTo-Json -Depth 10 | Set-Content -Path $wingetWin32AppsJson
|
$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"
|
WriteLog "Added $($newApp.Name) to WinGetWin32Apps.json with priority $highestPriority"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user