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:
rbalsleyMSFT
2026-01-09 18:05:36 -08:00
parent e9652daba9
commit 53741632a4
+127 -12
View File
@@ -792,6 +792,9 @@ function Get-Apps {
if ($overrideMap.Count -gt 0) {
$winGetWin32Path = Join-Path -Path $OrchestrationPath -ChildPath 'WinGetWin32Apps.json'
if (Test-Path -Path $winGetWin32Path) {
# 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) {
@@ -820,13 +823,15 @@ function Get-Apps {
}
}
if ($changed) {
$appsDataUpdated | ConvertTo-Json -Depth 10 | Set-Content -Path $winGetWin32Path
$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 {
WriteLog "WinGetWin32Apps.json not found; no overrides applied."
}
@@ -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,19 +1156,36 @@ function Add-Win32SilentInstallCommand {
# Path to the JSON file
$wingetWin32AppsJson = "$OrchestrationPath\WinGetWin32Apps.json"
# 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
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 {
$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."
}
$appsData = @()
}
}
# 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]@{
@@ -1083,8 +1195,11 @@ function Add-Win32SilentInstallCommand {
Arguments = $silentInstallSwitch
}
# Write the updated JSON file using a temp+rename to reduce partial-write risk
$appsData += $newApp
$appsData | ConvertTo-Json -Depth 10 | Set-Content -Path $wingetWin32AppsJson
$jsonText = $appsData | ConvertTo-Json -Depth 10
Set-FileContentAtomic -Path $wingetWin32AppsJson -Content $jsonText
}
WriteLog "Added $($newApp.Name) to WinGetWin32Apps.json with priority $highestPriority"