3 Commits

Author SHA1 Message Date
Ludwig Lehnert
1eadb00989 (hopefully) finally fixed installation queueing
All checks were successful
Release / build-release (push) Successful in 5s
2026-07-03 10:29:43 +00:00
Ludwig Lehnert
81bd495af9 fix: retry busy queue requests
All checks were successful
Release / build-release (push) Successful in 3s
2026-07-01 15:39:14 +00:00
Ludwig Lehnert
b41bab4285 fix: harden queue and request handling
All checks were successful
Release / build-release (push) Successful in 3s
2026-07-01 15:03:29 +00:00
3 changed files with 644 additions and 80 deletions

View File

@@ -20,6 +20,8 @@ jobs:
- name: Build dist - name: Build dist
run: make dist run: make dist
env:
SOFTKAT_BUILD_COMMIT: ${{ github.sha }}
- name: Package release zip - name: Package release zip
run: | run: |

View File

@@ -4,16 +4,31 @@ set -euo pipefail
src="${1:-setup.ps1}" src="${1:-setup.ps1}"
out="${2:-dist/setup.ps1}" out="${2:-dist/setup.ps1}"
out_dir="$(dirname "$out")" out_dir="$(dirname "$out")"
build_commit="${SOFTKAT_BUILD_COMMIT:-${GITHUB_SHA:-}}"
if [ -z "$build_commit" ] && git rev-parse --verify HEAD >/dev/null 2>&1; then
build_commit="$(git rev-parse HEAD)"
fi
if [ -z "$build_commit" ]; then
build_commit="unknown"
fi
mkdir -p "$out_dir" mkdir -p "$out_dir"
payload_src="$(mktemp)"
trap 'rm -f "$payload_src"' EXIT
printf '# Softwarekatalog build commit: %s\n' "$build_commit" > "$payload_src"
cat "$src" >> "$payload_src"
if base64 --help 2>&1 | grep -q -- '-w'; then if base64 --help 2>&1 | grep -q -- '-w'; then
payload="$(base64 -w 0 "$src")" payload="$(base64 -w 0 "$payload_src")"
else else
payload="$(base64 "$src" | tr -d '\n')" payload="$(base64 "$payload_src" | tr -d '\n')"
fi fi
cat > "$out" <<EOF cat > "$out" <<EOF
# Softwarekatalog build commit: $build_commit
# ASCII-only bootstrap generated from setup.ps1. Do not edit. # ASCII-only bootstrap generated from setup.ps1. Do not edit.
[CmdletBinding()] [CmdletBinding()]
param( param(

703
setup.ps1
View File

@@ -46,6 +46,7 @@ $CatalogGuiPath = Join-Path $BaseDir 'Software-Catalog.ps1'
$CatalogLauncherPath = Join-Path $BaseDir 'Launch-Software-Catalog.vbs' $CatalogLauncherPath = Join-Path $BaseDir 'Launch-Software-Catalog.vbs'
$InstalledPackagesPath = Join-Path $BaseDir 'Installed-Packages.json' $InstalledPackagesPath = Join-Path $BaseDir 'Installed-Packages.json'
$QueueStatePath = Join-Path $BaseDir 'Queue-State.json' $QueueStatePath = Join-Path $BaseDir 'Queue-State.json'
$QueueLockInfoPath = Join-Path $BaseDir 'Queue-Lock.json'
$RequestsDir = Join-Path $BaseDir 'Requests' $RequestsDir = Join-Path $BaseDir 'Requests'
$ProcessedRequestsDir = Join-Path $BaseDir 'Processed' $ProcessedRequestsDir = Join-Path $BaseDir 'Processed'
$FailedRequestsDir = Join-Path $BaseDir 'Failed' $FailedRequestsDir = Join-Path $BaseDir 'Failed'
@@ -58,6 +59,7 @@ $PolicyMaxEntries = 500
$LegacyQueueTaskName = 'Softwarekatalog - Winget Queue' $LegacyQueueTaskName = 'Softwarekatalog - Winget Queue'
$DaemonTaskName = 'Softwarekatalog - Daemon' $DaemonTaskName = 'Softwarekatalog - Daemon'
$RequestTaskName = 'Softwarekatalog - Requests' $RequestTaskName = 'Softwarekatalog - Requests'
$QueueTaskName = 'Softwarekatalog - Queue'
$UpgradeTaskName = 'Softwarekatalog - Winget Upgrade' $UpgradeTaskName = 'Softwarekatalog - Winget Upgrade'
$WingetMutexName = 'Global\Softwarekatalog-Winget' $WingetMutexName = 'Global\Softwarekatalog-Winget'
$QueueStateMutexName = 'Global\Softwarekatalog-QueueState' $QueueStateMutexName = 'Global\Softwarekatalog-QueueState'
@@ -68,6 +70,8 @@ $CommonDesktopDir = [Environment]::GetFolderPath('CommonDesktopDirectory')
$CommonProgramsDir = [Environment]::GetFolderPath('CommonPrograms') $CommonProgramsDir = [Environment]::GetFolderPath('CommonPrograms')
$DesktopShortcutPath = Join-Path $CommonDesktopDir "$CatalogDisplayName.lnk" $DesktopShortcutPath = Join-Path $CommonDesktopDir "$CatalogDisplayName.lnk"
$StartMenuShortcutPath = Join-Path $CommonProgramsDir "$CatalogDisplayName.lnk" $StartMenuShortcutPath = Join-Path $CommonProgramsDir "$CatalogDisplayName.lnk"
$script:QueueProgressThrottle = @{}
$script:SoftwarekatalogBuildCommit = $null
# ---------------------------- # ----------------------------
# Utility functions # Utility functions
@@ -91,6 +95,88 @@ function Write-Log {
Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8
} }
function Get-SoftwarekatalogBuildCommit {
if (-not [string]::IsNullOrWhiteSpace($script:SoftwarekatalogBuildCommit)) {
return $script:SoftwarekatalogBuildCommit
}
$paths = @()
if ($PSCommandPath) {
$paths += $PSCommandPath
}
elseif ($MyInvocation.MyCommand.Path) {
$paths += $MyInvocation.MyCommand.Path
}
elseif ($LocalScriptPath) {
$paths += $LocalScriptPath
}
foreach ($path in @($paths | Select-Object -Unique)) {
if ([string]::IsNullOrWhiteSpace($path)) { continue }
if (-not (Test-Path -LiteralPath $path)) { continue }
try {
foreach ($line in @(Get-Content -LiteralPath $path -TotalCount 5 -Encoding UTF8 -ErrorAction Stop)) {
$match = [regex]::Match([string]$line, '^#\s*Softwarekatalog build commit:\s*(.+?)\s*$')
if ($match.Success) {
$script:SoftwarekatalogBuildCommit = $match.Groups[1].Value
return $script:SoftwarekatalogBuildCommit
}
}
}
catch {
}
}
$script:SoftwarekatalogBuildCommit = 'unknown'
return $script:SoftwarekatalogBuildCommit
}
function Write-QueueLockOwnerInfo {
param([Parameter(Mandatory)][string]$Operation)
try {
Ensure-Directory -Path $BaseDir
$owner = [ordered]@{
Pid = $PID
Mode = $Mode
Operation = $Operation
StartedAt = (Get-Date).ToString('o')
ScriptPath = $PSCommandPath
BuildCommit = Get-SoftwarekatalogBuildCommit
}
$owner | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $QueueLockInfoPath -Encoding UTF8 -ErrorAction Stop
}
catch {
}
}
function Clear-QueueLockOwnerInfo {
try {
if (Test-Path -LiteralPath $QueueLockInfoPath) {
Remove-Item -LiteralPath $QueueLockInfoPath -Force -ErrorAction Stop
}
}
catch {
}
}
function Get-QueueLockOwnerSummary {
if (-not (Test-Path -LiteralPath $QueueLockInfoPath)) {
return 'owner=unknown'
}
try {
$raw = Get-Content -LiteralPath $QueueLockInfoPath -Raw -Encoding UTF8 -ErrorAction Stop
if ([string]::IsNullOrWhiteSpace($raw)) { return 'owner=unknown' }
$owner = $raw | ConvertFrom-Json
return "ownerPid=$($owner.Pid) ownerMode='$($owner.Mode)' operation='$($owner.Operation)' startedAt='$($owner.StartedAt)' build='$($owner.BuildCommit)' script='$($owner.ScriptPath)'"
}
catch {
return "owner=unreadable ($($_.Exception.Message))"
}
}
function Set-DirectoryAcl { function Set-DirectoryAcl {
param( param(
[Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Path,
@@ -394,6 +480,75 @@ function New-QueueStateObject {
} }
} }
function Backup-CorruptQueueState {
param([string]$Reason)
if (-not (Test-Path -LiteralPath $QueueStatePath)) { return }
try {
$backupPath = Join-Path $BaseDir ("Queue-State.corrupt-{0}.json" -f (Get-Date -Format 'yyyyMMddHHmmss'))
Copy-Item -LiteralPath $QueueStatePath -Destination $backupPath -Force -ErrorAction Stop
Write-Log "Backed up corrupt queue state to '$backupPath': $Reason" 'WARN'
}
catch {
Write-Log "Could not back up corrupt queue state: $($_.Exception.Message)" 'WARN'
}
}
function Normalize-QueueState {
param($State)
if (-not $State -or $State -is [System.Array] -or $State -isnot [pscustomobject]) {
return New-QueueStateObject
}
if (-not $State.PSObject.Properties['Version']) { $State | Add-Member -NotePropertyName Version -NotePropertyValue 0 }
if (-not $State.PSObject.Properties['UpdatedAt']) { $State | Add-Member -NotePropertyName UpdatedAt -NotePropertyValue (Get-Date).ToString('o') }
if (-not $State.PSObject.Properties['Current']) { $State | Add-Member -NotePropertyName Current -NotePropertyValue $null }
if (-not $State.PSObject.Properties['Queue']) {
$State | Add-Member -NotePropertyName Queue -NotePropertyValue @()
}
elseif (-not $State.Queue) {
$State.Queue = @()
}
else {
$State.Queue = @(@($State.Queue) | Where-Object { $null -ne $_ })
}
return $State
}
function Get-QueueItemSortTimestamp {
param($Item)
foreach ($propertyName in @('FinishedAt','StartedAt','CreatedAt')) {
if ($Item -and $Item.PSObject.Properties[$propertyName] -and $Item.$propertyName) {
try { return [DateTime]::Parse([string]$Item.$propertyName) }
catch { }
}
}
return [DateTime]::MinValue
}
function Compact-QueueState {
param($State)
$State = Normalize-QueueState -State $State
$active = New-Object 'System.Collections.Generic.List[object]'
$finished = New-Object 'System.Collections.Generic.List[object]'
foreach ($item in @($State.Queue)) {
if (-not $item) { continue }
if ($item.State -in @('Queued','Running')) { [void]$active.Add($item) }
else { [void]$finished.Add($item) }
}
$keptFinished = @($finished | Sort-Object { Get-QueueItemSortTimestamp -Item $_ } -Descending | Select-Object -First 100)
$State.Queue = @(@($active | Sort-Object CreatedAt) + @($keptFinished | Sort-Object { Get-QueueItemSortTimestamp -Item $_ }))
return $State
}
function Read-QueueState { function Read-QueueState {
if (-not (Test-Path -LiteralPath $QueueStatePath)) { if (-not (Test-Path -LiteralPath $QueueStatePath)) {
return New-QueueStateObject return New-QueueStateObject
@@ -406,19 +561,16 @@ function Read-QueueState {
} }
$state = $raw | ConvertFrom-Json $state = $raw | ConvertFrom-Json
if (-not $state.PSObject.Properties['Version']) { $state | Add-Member -NotePropertyName Version -NotePropertyValue 0 } if ($state -is [System.Array] -or $state -isnot [pscustomobject]) {
if (-not $state.PSObject.Properties['UpdatedAt']) { $state | Add-Member -NotePropertyName UpdatedAt -NotePropertyValue (Get-Date).ToString('o') } Backup-CorruptQueueState -Reason "Queue state root is not a JSON object."
if (-not $state.PSObject.Properties['Current']) { $state | Add-Member -NotePropertyName Current -NotePropertyValue $null } return New-QueueStateObject
if (-not $state.PSObject.Properties['Queue']) {
$state | Add-Member -NotePropertyName Queue -NotePropertyValue @()
} }
elseif (-not $state.Queue) {
$state.Queue = @() return Normalize-QueueState -State $state
}
return $state
} }
catch { catch {
Write-Log "Failed to read queue state: $($_.Exception.Message)" 'WARN' Write-Log "Failed to read queue state: $($_.Exception.Message)" 'WARN'
Backup-CorruptQueueState -Reason $_.Exception.Message
return New-QueueStateObject return New-QueueStateObject
} }
} }
@@ -427,6 +579,7 @@ function Write-QueueState {
param([Parameter(Mandatory)]$State) param([Parameter(Mandatory)]$State)
Ensure-Directory -Path $BaseDir Ensure-Directory -Path $BaseDir
$State = Compact-QueueState -State $State
$tempPath = Join-Path $BaseDir ("Queue-State-{0}.json.tmp" -f [Guid]::NewGuid().ToString('N')) $tempPath = Join-Path $BaseDir ("Queue-State-{0}.json.tmp" -f [Guid]::NewGuid().ToString('N'))
try { try {
$State | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $tempPath -Encoding UTF8 $State | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $tempPath -Encoding UTF8
@@ -440,26 +593,46 @@ function Write-QueueState {
} }
function Invoke-WithQueueStateLock { function Invoke-WithQueueStateLock {
param([Parameter(Mandatory)][scriptblock]$ScriptBlock) param(
[Parameter(Mandatory)][scriptblock]$ScriptBlock,
[int]$TimeoutSeconds = 30,
[string]$Operation = 'QueueState'
)
$mutex = New-Object System.Threading.Mutex($false, $QueueStateMutexName) $mutex = New-Object System.Threading.Mutex($false, $QueueStateMutexName)
$hasLock = $false $hasLock = $false
try { try {
$hasLock = $mutex.WaitOne([TimeSpan]::FromSeconds(30)) try {
if (-not $hasLock) { $hasLock = $mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds))
throw "Timed out waiting for queue state lock '$QueueStateMutexName'." }
catch [System.Threading.AbandonedMutexException] {
$hasLock = $true
Write-Log "Recovered abandoned queue state lock '$QueueStateMutexName'." 'WARN'
} }
if (-not $hasLock) {
$owner = Get-QueueLockOwnerSummary
throw (New-Object System.TimeoutException -ArgumentList "Timed out waiting for queue state lock '$QueueStateMutexName' ($owner).")
}
Write-QueueLockOwnerInfo -Operation $Operation
& $ScriptBlock & $ScriptBlock
} }
finally { finally {
if ($hasLock) { $mutex.ReleaseMutex() } if ($hasLock) {
Clear-QueueLockOwnerInfo
$mutex.ReleaseMutex()
}
$mutex.Dispose() $mutex.Dispose()
} }
} }
function Update-QueueState { function Update-QueueState {
param([Parameter(Mandatory)][scriptblock]$ScriptBlock) param(
[Parameter(Mandatory)][scriptblock]$ScriptBlock,
[int]$TimeoutSeconds = 30,
[string]$Operation = 'UpdateQueueState'
)
Invoke-WithQueueStateLock -ScriptBlock { Invoke-WithQueueStateLock -ScriptBlock {
$state = Read-QueueState $state = Read-QueueState
@@ -468,11 +641,41 @@ function Update-QueueState {
$state.UpdatedAt = (Get-Date).ToString('o') $state.UpdatedAt = (Get-Date).ToString('o')
Write-QueueState -State $state Write-QueueState -State $state
return $state return $state
} } -TimeoutSeconds $TimeoutSeconds -Operation $Operation
} }
function Get-QueueStateSnapshot { function Get-QueueStateSnapshot {
Invoke-WithQueueStateLock -ScriptBlock { Read-QueueState } param([int]$TimeoutSeconds = 30)
Invoke-WithQueueStateLock -ScriptBlock { Read-QueueState } -TimeoutSeconds $TimeoutSeconds -Operation 'GetQueueStateSnapshot'
}
function Test-IsQueueStateLockTimeout {
param([Parameter(Mandatory)]$ErrorObject)
if ($ErrorObject -is [System.Management.Automation.ErrorRecord]) {
$exception = $ErrorObject.Exception
}
elseif ($ErrorObject -is [System.Exception]) {
$exception = $ErrorObject
}
else {
return $false
}
while ($exception) {
if ($exception -is [System.TimeoutException] -and $exception.Message -like "*$QueueStateMutexName*") {
return $true
}
if ($exception.Message -like "*Timed out waiting for queue state lock '$QueueStateMutexName'*") {
return $true
}
$exception = $exception.InnerException
}
return $false
} }
function New-QueueItem { function New-QueueItem {
@@ -532,7 +735,28 @@ function Update-QueueItemProgress {
[bool]$IsIndeterminate = $true [bool]$IsIndeterminate = $true
) )
$null = Update-QueueState -ScriptBlock { if (-not $script:QueueProgressThrottle) { $script:QueueProgressThrottle = @{} }
$now = Get-Date
$last = $script:QueueProgressThrottle[$Id]
if ($last) {
$sameStage = [string]::Equals([string]$last.Stage, [string]$Stage, [System.StringComparison]::Ordinal)
$sameText = [string]::Equals([string]$last.StageText, [string]$StageText, [System.StringComparison]::Ordinal)
$samePercent = ([string]$last.ProgressPercent) -eq ([string]$ProgressPercent)
$sameIndeterminate = ([bool]$last.IsIndeterminate) -eq ([bool]$IsIndeterminate)
if ($sameStage -and $sameText -and $samePercent -and $sameIndeterminate -and (($now - $last.UpdatedAt).TotalSeconds -lt 5)) {
return
}
}
$script:QueueProgressThrottle[$Id] = [pscustomobject]@{
Stage = $Stage
StageText = $StageText
ProgressPercent = $ProgressPercent
IsIndeterminate = $IsIndeterminate
UpdatedAt = $now
}
$null = Update-QueueState -Operation "Progress:$Id" -ScriptBlock {
param($state) param($state)
foreach ($item in @($state.Queue)) { foreach ($item in @($state.Queue)) {
if (-not $item) { continue } if (-not $item) { continue }
@@ -568,7 +792,10 @@ function Get-RequiredAliasSet {
} }
function Add-QueueItem { function Add-QueueItem {
param([Parameter(Mandatory)]$Item) param(
[Parameter(Mandatory)]$Item,
[int]$TimeoutSeconds = 30
)
$result = $null $result = $null
$null = Update-QueueState -ScriptBlock { $null = Update-QueueState -ScriptBlock {
@@ -585,7 +812,7 @@ function Add-QueueItem {
$state.Queue = @($queue + $Item) $state.Queue = @($queue + $Item)
$script:QueueAddResult = [pscustomobject]@{ Added = $true; Item = $Item } $script:QueueAddResult = [pscustomobject]@{ Added = $true; Item = $Item }
} } -TimeoutSeconds $TimeoutSeconds -Operation "AddQueueItem:$($Item.Action):$($Item.Alias)"
$result = $script:QueueAddResult $result = $script:QueueAddResult
$script:QueueAddResult = $null $script:QueueAddResult = $null
@@ -598,7 +825,8 @@ function Add-SoftwareRequestToQueue {
[Parameter(Mandatory)][string]$Alias, [Parameter(Mandatory)][string]$Alias,
[string]$RequestedBy, [string]$RequestedBy,
[string]$RequestedBySid, [string]$RequestedBySid,
[ValidateSet('User','Required')][string]$Source = 'User' [ValidateSet('User','Required')][string]$Source = 'User',
[int]$TimeoutSeconds = 30
) )
$normalizedAlias = $Alias.Trim().ToLowerInvariant() $normalizedAlias = $Alias.Trim().ToLowerInvariant()
@@ -623,10 +851,12 @@ function Add-SoftwareRequestToQueue {
-RequestedBy $RequestedBy ` -RequestedBy $RequestedBy `
-RequestedBySid $RequestedBySid -RequestedBySid $RequestedBySid
return Add-QueueItem -Item $item return Add-QueueItem -Item $item -TimeoutSeconds $TimeoutSeconds
} }
function Ensure-RequiredSoftwareQueued { function Ensure-RequiredSoftwareQueued {
param([int]$TimeoutSeconds = 30)
$addedCount = 0 $addedCount = 0
if (-not (Test-SoftwareCatalogPolicyEnabled)) { return 0 } if (-not (Test-SoftwareCatalogPolicyEnabled)) { return 0 }
if (-not (Test-RequiredEnforcementEnabled)) { return 0 } if (-not (Test-RequiredEnforcementEnabled)) { return 0 }
@@ -643,7 +873,7 @@ function Ensure-RequiredSoftwareQueued {
continue continue
} }
$result = Add-SoftwareRequestToQueue -Action Install -Alias $alias -RequestedBy 'Policy' -RequestedBySid 'Policy' -Source Required $result = Add-SoftwareRequestToQueue -Action Install -Alias $alias -RequestedBy 'Policy' -RequestedBySid 'Policy' -Source Required -TimeoutSeconds $TimeoutSeconds
if ($result -and $result.Added) { if ($result -and $result.Added) {
$addedCount++ $addedCount++
Write-Log "Queued required software alias '$alias' package '$($app.PackageId)'." Write-Log "Queued required software alias '$alias' package '$($app.PackageId)'."
@@ -694,6 +924,29 @@ function Test-WingetLockAvailable {
} }
} }
function Test-QueueStateLockAvailable {
$mutex = New-Object System.Threading.Mutex($false, $QueueStateMutexName)
$hasLock = $false
try {
try {
$hasLock = $mutex.WaitOne(0)
}
catch [System.Threading.AbandonedMutexException] {
$hasLock = $true
Write-Log "Recovered abandoned queue state lock '$QueueStateMutexName' during availability check." 'WARN'
}
return $hasLock
}
finally {
if ($hasLock) {
$mutex.ReleaseMutex()
}
$mutex.Dispose()
}
}
function ConvertTo-ProcessArgument { function ConvertTo-ProcessArgument {
param([Parameter(Mandatory)][string]$Value) param([Parameter(Mandatory)][string]$Value)
@@ -750,7 +1003,41 @@ function Start-SoftwarekatalogWorker {
} }
function Start-QueueWorker { function Start-QueueWorker {
Start-SoftwarekatalogWorker -WorkerMode ProcessQueue try {
$task = Get-ScheduledTask -TaskName $QueueTaskName -ErrorAction SilentlyContinue
if ($task) {
Start-ScheduledTask -TaskName $QueueTaskName -ErrorAction Stop
Write-Log "Started scheduled queue task '$QueueTaskName'."
return $true
}
}
catch {
Write-Log "Could not start scheduled queue task '$QueueTaskName': $($_.Exception.Message)" 'WARN'
}
try {
Start-SoftwarekatalogWorker -WorkerMode ProcessQueue
Write-Log 'Started queue worker process fallback.' 'WARN'
return $true
}
catch {
Write-Log "Could not start queue worker process fallback: $($_.Exception.Message)" 'ERROR'
return $false
}
}
function Start-QueueWorkerIfAvailable {
if (-not (Test-WingetLockAvailable)) {
Write-Log 'Queue worker not started because winget lock is already held.'
return $false
}
if (-not (Test-QueueStateLockAvailable)) {
Write-Log 'Queue worker not started because queue state lock is already held.' 'WARN'
return $false
}
return (Start-QueueWorker)
} }
function Get-WingetPath { function Get-WingetPath {
@@ -2097,9 +2384,7 @@ function Unregister-LegacyQueueTask {
} }
} }
function Register-OrUpdateTasks { function Unregister-ObsoleteDaemonTask {
Unregister-LegacyQueueTask
try { try {
$existingDaemon = Get-ScheduledTask -TaskName $DaemonTaskName -ErrorAction SilentlyContinue $existingDaemon = Get-ScheduledTask -TaskName $DaemonTaskName -ErrorAction SilentlyContinue
if ($existingDaemon) { if ($existingDaemon) {
@@ -2111,7 +2396,62 @@ function Register-OrUpdateTasks {
catch { catch {
Write-Log "Could not remove daemon task '$DaemonTaskName': $($_.Exception.Message)" 'WARN' Write-Log "Could not remove daemon task '$DaemonTaskName': $($_.Exception.Message)" 'WARN'
} }
}
function Stop-SoftwarekatalogScheduledTasksForUpdate {
foreach ($taskName in @($RequestTaskName, $QueueTaskName, $UpgradeTaskName, $LegacyQueueTaskName, $DaemonTaskName)) {
if ([string]::IsNullOrWhiteSpace($taskName)) { continue }
try {
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($task) {
Write-Log "Stopping scheduled task '$taskName' before update."
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
for ($attempt = 0; $attempt -lt 10; $attempt++) {
$currentTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if (-not $currentTask -or [string]$currentTask.State -ne "Running") { break }
Start-Sleep -Milliseconds 500
}
}
}
catch {
Write-Log "Could not stop scheduled task '$taskName': $($_.Exception.Message)" 'WARN'
}
}
}
function Stop-ObsoleteDaemonProcesses {
try {
$processes = @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop)
}
catch {
Write-Log "Could not enumerate processes for Softwarekatalog worker cleanup: $($_.Exception.Message)" 'WARN'
return
}
foreach ($process in $processes) {
if (-not $process) { continue }
if ([int]$process.ProcessId -eq $PID) { continue }
if ([string]$process.Name -notin @('powershell.exe','pwsh.exe')) { continue }
$commandLine = [string]$process.CommandLine
if ([string]::IsNullOrWhiteSpace($commandLine)) { continue }
$matchesScriptPath = $commandLine.IndexOf($LocalScriptPath, [System.StringComparison]::OrdinalIgnoreCase) -ge 0
$matchesScriptName = ($commandLine.IndexOf('SelfServiceWinget.ps1', [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -and $commandLine.IndexOf($BaseDir, [System.StringComparison]::OrdinalIgnoreCase) -ge 0)
if (-not ($matchesScriptPath -or $matchesScriptName)) { continue }
try {
Write-Log "Stopping existing Softwarekatalog process pid=$($process.ProcessId)."
Stop-Process -Id ([int]$process.ProcessId) -Force -ErrorAction Stop
}
catch {
Write-Log "Could not stop Softwarekatalog process pid=$($process.ProcessId): $($_.Exception.Message)" 'WARN'
}
}
}
function Register-OrUpdateTasks {
$principal = New-ScheduledTaskPrincipal ` $principal = New-ScheduledTaskPrincipal `
-UserId 'SYSTEM' ` -UserId 'SYSTEM' `
-LogonType ServiceAccount ` -LogonType ServiceAccount `
@@ -2141,6 +2481,23 @@ function Register-OrUpdateTasks {
Register-ScheduledTask -TaskName $RequestTaskName -InputObject $requestTask -Force | Out-Null Register-ScheduledTask -TaskName $RequestTaskName -InputObject $requestTask -Force | Out-Null
Write-Log "Registering/updating scheduled task '$QueueTaskName'."
$queueArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$LocalScriptPath`" -Mode ProcessQueue"
$queueAction = New-ScheduledTaskAction -Execute $PowerShellExe -Argument $queueArgs
$queueSettings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-MultipleInstances IgnoreNew `
-ExecutionTimeLimit (New-TimeSpan -Hours 3)
$queueTask = New-ScheduledTask `
-Action $queueAction `
-Principal $principal `
-Settings $queueSettings
Register-ScheduledTask -TaskName $QueueTaskName -InputObject $queueTask -Force | Out-Null
Write-Log "Registering/updating scheduled task '$UpgradeTaskName'." Write-Log "Registering/updating scheduled task '$UpgradeTaskName'."
$upgradeArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$LocalScriptPath`" -Mode UpgradeAll" $upgradeArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$LocalScriptPath`" -Mode UpgradeAll"
$upgradeAction = New-ScheduledTaskAction -Execute $PowerShellExe -Argument $upgradeArgs $upgradeAction = New-ScheduledTaskAction -Execute $PowerShellExe -Argument $upgradeArgs
@@ -2444,16 +2801,26 @@ function Invoke-RequiredSoftwarePolicy {
} }
} }
$queued = Ensure-RequiredSoftwareQueued try {
$queued = Ensure-RequiredSoftwareQueued -TimeoutSeconds 5
}
catch {
if (Test-IsQueueStateLockTimeout -ErrorObject $_) {
Write-Log "Required software queueing skipped because the queue state lock is busy: $($_.Exception.Message)" 'WARN'
return [pscustomobject]@{ Installed = 0; Failed = 0; Skipped = 0; Queued = 0 }
}
throw
}
if ($queued -gt 0) { if ($queued -gt 0) {
Start-QueueWorker [void](Start-QueueWorkerIfAvailable)
} }
return [pscustomobject]@{ Installed = 0; Failed = 0; Skipped = 0; Queued = $queued } return [pscustomobject]@{ Installed = 0; Failed = 0; Skipped = 0; Queued = $queued }
} }
function Reset-RunningQueueItems { function Reset-RunningQueueItems {
$null = Update-QueueState -ScriptBlock { $null = Update-QueueState -Operation 'ResetRunningQueueItems' -ScriptBlock {
param($state) param($state)
foreach ($item in @($state.Queue)) { foreach ($item in @($state.Queue)) {
if (-not $item) { continue } if (-not $item) { continue }
@@ -2472,7 +2839,7 @@ function Reset-RunningQueueItems {
function Get-NextQueuedItem { function Get-NextQueuedItem {
$script:NextQueueItem = $null $script:NextQueueItem = $null
$null = Update-QueueState -ScriptBlock { $null = Update-QueueState -Operation 'GetNextQueuedItem' -ScriptBlock {
param($state) param($state)
foreach ($item in (@($state.Queue) | Sort-Object CreatedAt)) { foreach ($item in (@($state.Queue) | Sort-Object CreatedAt)) {
if (-not $item) { continue } if (-not $item) { continue }
@@ -2512,7 +2879,7 @@ function Complete-QueueItem {
[string]$Message [string]$Message
) )
$null = Update-QueueState -ScriptBlock { $null = Update-QueueState -Operation "CompleteQueueItem:${Id}:${State}" -ScriptBlock {
param($queueState) param($queueState)
foreach ($item in @($queueState.Queue)) { foreach ($item in @($queueState.Queue)) {
if (-not $item) { continue } if (-not $item) { continue }
@@ -2613,7 +2980,7 @@ function Invoke-ProcessQueueMode {
} }
} }
$null = Update-QueueState -ScriptBlock { param($state) $state.Current = $null } $null = Update-QueueState -Operation 'ClearCurrentQueueItem' -ScriptBlock { param($state) $state.Current = $null }
} }
} }
@@ -2622,14 +2989,25 @@ function Invoke-UpgradeAllMode {
Ensure-Directory -Path $LogDir Ensure-Directory -Path $LogDir
$item = New-QueueItem -Action UpgradeAll -DisplayName 'winget upgrade --all' -Source System -RequestedBy 'System' -RequestedBySid 'System' $item = New-QueueItem -Action UpgradeAll -DisplayName 'winget upgrade --all' -Source System -RequestedBy 'System' -RequestedBySid 'System'
$result = Add-QueueItem -Item $item try {
$result = Add-QueueItem -Item $item -TimeoutSeconds 5
}
catch {
if (Test-IsQueueStateLockTimeout -ErrorObject $_) {
Write-Log "Skipped 2-hour winget upgrade queueing because the queue state lock is busy: $($_.Exception.Message)" 'WARN'
return
}
throw
}
if ($result.Added) { if ($result.Added) {
Write-Log 'Queued 2-hour winget upgrade.' Write-Log 'Queued 2-hour winget upgrade.'
} }
else { else {
Write-Log '2-hour winget upgrade is already queued or running.' Write-Log '2-hour winget upgrade is already queued or running.'
} }
Start-QueueWorker
[void](Start-QueueWorkerIfAvailable)
} }
function Invoke-ApplyPolicyMode { function Invoke-ApplyPolicyMode {
@@ -2666,9 +3044,19 @@ function Invoke-ProcessRequestMode {
return return
} }
$result = Add-SoftwareRequestToQueue -Action $RequestAction -Alias $alias -RequestedBy $RequestedBy -RequestedBySid $RequestedBySid -Source User try {
$result = Add-SoftwareRequestToQueue -Action $RequestAction -Alias $alias -RequestedBy $RequestedBy -RequestedBySid $RequestedBySid -Source User -TimeoutSeconds 5
}
catch {
if (Test-IsQueueStateLockTimeout -ErrorObject $_) {
Write-Log "Worker request action=$RequestAction alias='$alias' could not be queued because the queue state lock is busy: $($_.Exception.Message)" 'WARN'
return
}
throw
}
Write-Log "Queued worker request action=$RequestAction alias='$alias' added=$($result.Added) RequestedBy='$RequestedBy' Sid='$RequestedBySid'" Write-Log "Queued worker request action=$RequestAction alias='$alias' added=$($result.Added) RequestedBy='$RequestedBy' Sid='$RequestedBySid'"
Start-QueueWorker [void](Start-QueueWorkerIfAvailable)
} }
function Get-RequestFileOwnerInfo { function Get-RequestFileOwnerInfo {
@@ -2699,6 +3087,98 @@ function Get-RequestFileOwnerInfo {
} }
} }
function Assert-RequestJsonDepth {
param(
[Parameter(Mandatory)][string]$Raw,
[int]$MaxDepth = 8
)
$depth = 0
$inString = $false
$escaped = $false
for ($i = 0; $i -lt $Raw.Length; $i++) {
$code = [int][char]$Raw[$i]
if ($inString) {
if ($escaped) {
$escaped = $false
continue
}
if ($code -eq 92) {
$escaped = $true
continue
}
if ($code -eq 34) {
$inString = $false
}
continue
}
if ($code -eq 34) {
$inString = $true
continue
}
if ($code -eq 123 -or $code -eq 91) {
$depth++
if ($depth -gt $MaxDepth) {
throw "Request JSON nesting exceeds maximum depth $MaxDepth."
}
continue
}
if ($code -eq 125 -or $code -eq 93) {
if ($depth -lt 1) {
throw 'Request JSON has an unexpected closing bracket.'
}
$depth--
}
}
if ($inString) {
throw 'Request JSON string is unterminated.'
}
if ($depth -ne 0) {
throw 'Request JSON is incomplete.'
}
}
function Get-RequestScalarValue {
param(
[Parameter(Mandatory)]$Request,
[Parameter(Mandatory)][string[]]$Names,
[Parameter(Mandatory)][string]$DisplayName,
[int]$MaxLength = 256
)
foreach ($name in $Names) {
$property = $Request.PSObject.Properties[$name]
if (-not $property) { continue }
$value = $property.Value
if ($null -eq $value) { continue }
if ($value -is [System.Array] -or $value -is [System.Collections.IDictionary] -or $value -is [pscustomobject]) {
throw "Request property '$name' must be a scalar value."
}
$text = ([string]$value).Trim()
if ([string]::IsNullOrWhiteSpace($text)) { continue }
if ($text.Length -gt $MaxLength) {
throw "Request property '$name' is too long ($($text.Length) characters; max=$MaxLength)."
}
return $text
}
throw "Request is missing $DisplayName."
}
function Move-RequestFile { function Move-RequestFile {
param( param(
[Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Path,
@@ -2713,12 +3193,61 @@ function Move-RequestFile {
$destination = Join-Path $DestinationDir ("{0}-{1}.json" -f [IO.Path]::GetFileNameWithoutExtension($leaf), [Guid]::NewGuid().ToString('N')) $destination = Join-Path $DestinationDir ("{0}-{1}.json" -f [IO.Path]::GetFileNameWithoutExtension($leaf), [Guid]::NewGuid().ToString('N'))
} }
Move-Item -LiteralPath $Path -Destination $destination -Force Microsoft.PowerShell.Management\Move-Item -LiteralPath $Path -Destination $destination -Force -ErrorAction Stop
Set-FileAclAdminsOnly -Path $destination Set-FileAclAdminsOnly -Path $destination
if (-not [string]::IsNullOrWhiteSpace($ErrorMessage)) { if (-not [string]::IsNullOrWhiteSpace($ErrorMessage)) {
Set-Content -LiteralPath ($destination + '.error.txt') -Value $ErrorMessage -Encoding UTF8 $errorPath = $destination + '.error.txt'
Set-FileAclAdminsOnly -Path ($destination + '.error.txt') try {
Microsoft.PowerShell.Management\Set-Content -LiteralPath $errorPath -Value $ErrorMessage -Encoding UTF8 -ErrorAction Stop
Set-FileAclAdminsOnly -Path $errorPath
}
catch {
Write-Log "Could not write request error file '$errorPath': $($_.Exception.Message)" 'WARN'
}
}
}
function Test-IsTransientRequestFailureMessage {
param([string]$Message)
if ([string]::IsNullOrWhiteSpace($Message)) { return $false }
return (
$Message -like "*$QueueStateMutexName*" -or
$Message -like "*queue state lock*" -or
$Message -like "*Aufruftiefe*" -or
$Message -like "*call depth*" -or
$Message -like "*recursion*"
)
}
function Restore-QueueLockFailedRequests {
Ensure-Directory -Path $RequestsDir
Ensure-Directory -Path $FailedRequestsDir
$errorFiles = @(Get-ChildItem -LiteralPath $FailedRequestsDir -Filter '*.json.error.txt' -File -ErrorAction SilentlyContinue)
foreach ($errorFile in $errorFiles) {
try {
$message = Get-Content -LiteralPath $errorFile.FullName -Raw -Encoding UTF8 -ErrorAction Stop
if (-not (Test-IsTransientRequestFailureMessage -Message $message)) { continue }
$requestName = $errorFile.Name.Substring(0, $errorFile.Name.Length - '.error.txt'.Length)
$requestPath = Join-Path $FailedRequestsDir $requestName
if (-not (Test-Path -LiteralPath $requestPath)) { continue }
$destination = Join-Path $RequestsDir $requestName
if (Test-Path -LiteralPath $destination) {
$destination = Join-Path $RequestsDir ("{0}-{1}.json" -f [IO.Path]::GetFileNameWithoutExtension($requestName), [Guid]::NewGuid().ToString('N'))
}
Move-Item -LiteralPath $requestPath -Destination $destination -Force -ErrorAction Stop
Remove-Item -LiteralPath $errorFile.FullName -Force -ErrorAction SilentlyContinue
Write-Log "Restored failed request '$requestName' for retry after queue lock timeout."
}
catch {
Write-Log "Could not restore failed request '$($errorFile.Name)': $($_.Exception.Message)" 'WARN'
}
} }
} }
@@ -2747,19 +3276,13 @@ function Invoke-ProcessRequestsMode {
throw 'Request file is empty.' throw 'Request file is empty.'
} }
Assert-RequestJsonDepth -Raw $raw -MaxDepth 8
$request = $raw | ConvertFrom-Json $request = $raw | ConvertFrom-Json
$commandValue = $null if ($request -is [System.Array] -or $request -isnot [pscustomobject]) {
if ($request.PSObject.Properties['Command'] -and $request.Command) { throw 'Request JSON must be an object.'
$commandValue = [string]$request.Command
}
elseif ($request.PSObject.Properties['Action'] -and $request.Action) {
$commandValue = [string]$request.Action
}
if ([string]::IsNullOrWhiteSpace($commandValue)) {
throw 'Request is missing Command.'
} }
$commandValue = Get-RequestScalarValue -Request $request -Names @('Command','Action') -DisplayName 'Command' -MaxLength 32
$command = $commandValue.Trim().ToLowerInvariant() $command = $commandValue.Trim().ToLowerInvariant()
if ($command -in @('refreshstatus','getstatus')) { if ($command -in @('refreshstatus','getstatus')) {
$owner = Get-RequestFileOwnerInfo -Path $file.FullName $owner = Get-RequestFileOwnerInfo -Path $file.FullName
@@ -2785,27 +3308,21 @@ function Invoke-ProcessRequestsMode {
throw "Unsupported request command '$commandValue'." throw "Unsupported request command '$commandValue'."
} }
$aliasValue = $null $aliasValue = Get-RequestScalarValue -Request $request -Names @('AppAlias','Alias') -DisplayName 'AppAlias' -MaxLength 128
if ($request.PSObject.Properties['AppAlias'] -and $request.AppAlias) {
$aliasValue = [string]$request.AppAlias
}
elseif ($request.PSObject.Properties['Alias'] -and $request.Alias) {
$aliasValue = [string]$request.Alias
}
if ([string]::IsNullOrWhiteSpace($aliasValue)) {
throw 'Request is missing AppAlias.'
}
$alias = $aliasValue.Trim().ToLowerInvariant() $alias = $aliasValue.Trim().ToLowerInvariant()
$owner = Get-RequestFileOwnerInfo -Path $file.FullName $owner = Get-RequestFileOwnerInfo -Path $file.FullName
$result = Add-SoftwareRequestToQueue -Action $action -Alias $alias -RequestedBy $owner.Name -RequestedBySid $owner.Sid -Source User $result = Add-SoftwareRequestToQueue -Action $action -Alias $alias -RequestedBy $owner.Name -RequestedBySid $owner.Sid -Source User -TimeoutSeconds 5
Write-Log "Accepted request file '$($file.Name)' action=$action alias='$alias' added=$($result.Added) owner='$($owner.Name)' sid='$($owner.Sid)'." Write-Log "Accepted request file '$($file.Name)' action=$action alias='$alias' added=$($result.Added) owner='$($owner.Name)' sid='$($owner.Sid)'."
Move-RequestFile -Path $file.FullName -DestinationDir $ProcessedRequestsDir Move-RequestFile -Path $file.FullName -DestinationDir $ProcessedRequestsDir
$shouldStartQueue = $true $shouldStartQueue = $true
} }
catch { catch {
$message = $_.Exception.Message $message = $_.Exception.Message
if (Test-IsQueueStateLockTimeout -ErrorObject $_) {
Write-Log "Request file '$($file.Name)' will be retried because the queue state lock is busy: $message" 'WARN'
break
}
Write-Log "Rejected request file '$($file.Name)': $message" 'WARN' Write-Log "Rejected request file '$($file.Name)': $message" 'WARN'
try { try {
Move-RequestFile -Path $file.FullName -DestinationDir $FailedRequestsDir -ErrorMessage $message Move-RequestFile -Path $file.FullName -DestinationDir $FailedRequestsDir -ErrorMessage $message
@@ -2816,26 +3333,24 @@ function Invoke-ProcessRequestsMode {
} }
} }
$requiredQueued = Ensure-RequiredSoftwareQueued try {
if ($requiredQueued -gt 0) { $requiredQueued = Ensure-RequiredSoftwareQueued -TimeoutSeconds 5
Write-Log "Queued required software from request worker count=$requiredQueued." if ($requiredQueued -gt 0) {
$shouldStartQueue = $true Write-Log "Queued required software from request worker count=$requiredQueued."
}
foreach ($queuedItem in @((Get-QueueStateSnapshot).Queue)) {
if ($queuedItem -and $queuedItem.State -eq 'Queued') {
$shouldStartQueue = $true $shouldStartQueue = $true
break }
}
catch {
if (Test-IsQueueStateLockTimeout -ErrorObject $_) {
Write-Log "Required software queueing deferred because the queue state lock is busy: $($_.Exception.Message)" 'WARN'
}
else {
throw
} }
} }
if ($shouldStartQueue) { if ($shouldStartQueue) {
if (Test-WingetLockAvailable) { [void](Start-QueueWorkerIfAvailable)
Start-QueueWorker
}
else {
Write-Log 'Queue worker not started because winget lock is already held.'
}
} }
} }
@@ -2846,6 +3361,15 @@ function Install-Service {
Ensure-Directory -Path $ProcessedRequestsDir Ensure-Directory -Path $ProcessedRequestsDir
Ensure-Directory -Path $FailedRequestsDir Ensure-Directory -Path $FailedRequestsDir
Write-Log "Softwarekatalog build commit: $(Get-SoftwarekatalogBuildCommit)."
Stop-SoftwarekatalogScheduledTasksForUpdate
Stop-ObsoleteDaemonProcesses
Unregister-LegacyQueueTask
Unregister-ObsoleteDaemonTask
Restore-QueueLockFailedRequests
try { Reset-RunningQueueItems }
catch { Write-Log "Could not reset running queue items during install/update: $($_.Exception.Message)" 'WARN' }
Set-DirectoryAcl -Path $BaseDir -UsersCanModify:$false Set-DirectoryAcl -Path $BaseDir -UsersCanModify:$false
Set-DirectoryAcl -Path $LogDir -UsersCanModify:$false Set-DirectoryAcl -Path $LogDir -UsersCanModify:$false
Set-RequestDirectoryAcl -Path $RequestsDir Set-RequestDirectoryAcl -Path $RequestsDir
@@ -2866,6 +3390,24 @@ function Install-Service {
New-Shortcut -ShortcutPath $StartMenuShortcutPath New-Shortcut -ShortcutPath $StartMenuShortcutPath
Register-OrUpdateTasks Register-OrUpdateTasks
try {
$queueState = Get-QueueStateSnapshot -TimeoutSeconds 5
foreach ($queuedItem in @($queueState.Queue)) {
if ($queuedItem -and $queuedItem.State -eq 'Queued') {
[void](Start-QueueWorkerIfAvailable)
break
}
}
}
catch {
if (Test-IsQueueStateLockTimeout -ErrorObject $_) {
Write-Log "Could not inspect queued work during install/update because the queue state lock is busy: $($_.Exception.Message)" 'WARN'
}
else {
Write-Log "Could not inspect queued work during install/update: $($_.Exception.Message)" 'WARN'
}
}
Write-Log 'Install/update completed successfully.' Write-Log 'Install/update completed successfully.'
} }
@@ -2885,6 +3427,11 @@ try {
} }
catch { catch {
try { try {
if ($Mode -in @('ProcessRequests','UpgradeAll') -and (Test-IsQueueStateLockTimeout -ErrorObject $_)) {
Write-Log "Mode '$Mode' deferred because the queue state lock is busy: $($_.Exception.Message)" 'WARN'
return
}
Write-Log "Fatal error in mode '$Mode': $($_.Exception.Message)" 'ERROR' Write-Log "Fatal error in mode '$Mode': $($_.Exception.Message)" 'ERROR'
} }
catch { catch {