FixWU.ps1

<#PSScriptInfo
 
.VERSION 1.1.1
 
.GUID e80e227b-68fc-4317-bb35-4d0c05ab2aac
 
.AUTHOR papahuge
 
.COMPANYNAME
 
.COPYRIGHT (c) 2026 papahuge. Released under the MIT License.
 
.TAGS WindowsUpdate Windows Update Reset Repair Troubleshooting BITS SoftwareDistribution PSEdition_Desktop PSEdition_Core
 
.LICENSEURI https://github.com/papahuge/FixWU/blob/main/LICENSE
 
.PROJECTURI https://github.com/papahuge/FixWU
 
.ICONURI
 
.EXTERNALMODULEDEPENDENCIES
 
.REQUIREDSCRIPTS
 
.EXTERNALSCRIPTDEPENDENCIES
 
.RELEASENOTES
1.1.1: First PowerShell Gallery release (adds Gallery metadata).
Full history: https://github.com/papahuge/FixWU/releases
 
.PRIVATEDATA
 
#>


#Requires -RunAsAdministrator

<#
.SYNOPSIS
    FixWU - resets a stuck Windows Update download queue.
 
.DESCRIPTION
    Safely resets a stuck Windows Update download queue. It checks for problems
    a reset can't fix, backs up the update caches instead of deleting them, and
    tells you what actually worked.
 
    1. Checks for causes a cache reset can't fix (pending reboot, disabled
       service, WSUS / blocking policy, paused updates) and reports them.
    2. Clears the BITS and Delivery Optimization download queues.
    3. Stops the update services, then renames SoftwareDistribution and
       catroot2 to *.old. Windows rebuilds both; the .old copies are your undo.
       A backup left by an earlier run is replaced, so there's only ever one.
    4. (Optional) Resets the WinHTTP proxy and Winsock catalog.
    5. Restarts only the services that were running before.
 
    Once updates work again, run it with -Cleanup to delete the .old backups.
 
.PARAMETER ResetNetwork
    Also reset the WinHTTP proxy and Winsock catalog. Needs a reboot, and can
    break VPN clients or a proxy you configured on purpose.
 
.PARAMETER Repair
    Also run DISM /RestoreHealth and SFC afterwards. Slow (10-30+ minutes).
 
.PARAMETER Force
    Run even if Windows is waiting on a reboot to finish installing updates.
 
.PARAMETER Cleanup
    Delete the .old backups left by earlier resets and report the space freed.
    Doesn't run a reset. Add -WhatIf to preview, or -Confirm to approve each one.
 
.EXAMPLE
    FixWU
    Runs the reset (via the FixWU shortcut in your PowerShell profile).
 
.EXAMPLE
    FixWU -Repair
    Runs the reset, then repairs system files with DISM and SFC.
 
.EXAMPLE
    FixWU -Cleanup -WhatIf
    Shows which .old backups -Cleanup would delete, without deleting anything.
 
.EXAMPLE
    powershell -ExecutionPolicy Bypass -File .\FixWU.ps1
    Runs the script directly from an admin terminal.
#>

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
    Justification = 'Interactive console tool: the colored status lines are its whole UI.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
    Justification = 'The reset deliberately refuses -WhatIf: bitsadmin/netsh cannot do a dry run.')]
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Reset')]
param(
    [Parameter(ParameterSetName = 'Reset')]
    [switch]$ResetNetwork,

    [Parameter(ParameterSetName = 'Reset')]
    [switch]$Repair,

    [Parameter(ParameterSetName = 'Reset')]
    [switch]$Force,

    # Its own parameter set, so -Cleanup can't be mixed with the reset switches.
    [Parameter(ParameterSetName = 'Cleanup', Mandatory)]
    [switch]$Cleanup
)

# -WhatIf / -Confirm only work with -Cleanup. The reset calls tools such as
# bitsadmin and netsh that can't "pretend", so a reset -WhatIf would be a lie.
if (-not $Cleanup -and ($PSBoundParameters.ContainsKey('WhatIf') -or $PSBoundParameters.ContainsKey('Confirm'))) {
    Write-Warning "-WhatIf and -Confirm only work together with -Cleanup."
    exit 1
}

# Color theme: Blue / Teal / HotPink.
# Write-Host only knows the 16 console colors, so Teal and HotPink use the
# closest matches (Cyan and Magenta). Change any of these in one place.
$Theme = @{
    Frame = 'Blue'      # banner and borders
    Step  = 'Magenta'   # step headings (HotPink)
    Good  = 'Cyan'      # success lines (Teal)
    Warn  = 'Yellow'    # matches Write-Warning, so problems still stand out
    Bad   = 'Red'       # failures
}

$cantFix  = [System.Collections.Generic.List[string]]::new()   # causes a reset won't solve
$failures = [System.Collections.Generic.List[string]]::new()   # steps that didn't work
$logPath  = Join-Path $env:TEMP ("FixWU_{0:yyyyMMdd_HHmmss}.log" -f (Get-Date))
# Always log for real, even in a -Cleanup -WhatIf / -Confirm preview. Setting
# the preferences inside this block works on every PowerShell version (passing
# -WhatIf:$false doesn't: some versions' transcript cmdlets lack the switch).
& { $WhatIfPreference = $false; $ConfirmPreference = 'None'; Start-Transcript -Path $logPath | Out-Null }

# UsoSvc (Update Orchestrator) is listed first so it gets stopped first:
# left running, it can restart wuauserv behind our back mid-reset.
$services   = 'UsoSvc', 'wuauserv', 'bits', 'cryptsvc'
$wasRunning = @(Get-Service -Name $services -ErrorAction SilentlyContinue |
                Where-Object Status -eq 'Running' |
                ForEach-Object Name)

$cacheFolders = @(
    "$env:SystemRoot\SoftwareDistribution",
    "$env:SystemRoot\System32\catroot2"
)

function Write-Step([string]$Text) { Write-Host "`n$Text" -ForegroundColor $Theme.Step }

# Stops the log, then makes sure you can open it from a normal window. Files
# created from an admin window can end up readable only by admins, depending
# on the folder's permissions.
function Close-Log {
    # In PowerShell 7, Stop-Transcript obeys -WhatIf: during a -Cleanup -WhatIf
    # it would "pretend" to stop and leave the log running in your session.
    $WhatIfPreference = $false; $ConfirmPreference = 'None'
    Stop-Transcript | Out-Null
    $me = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
    icacls.exe $logPath /grant "*${me}:(R)" 2>$null | Out-Null
}

function Stop-UpdateService {
    foreach ($name in $services) {
        $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
        if (-not $svc -or $svc.Status -eq 'Stopped') { continue }
        try {
            Stop-Service -InputObject $svc -Force -ErrorAction Stop -WarningAction SilentlyContinue
        } catch {
            Write-Warning " Couldn't stop ${name}: $($_.Exception.Message)"
        }
    }
}

# Renames a folder to <name>.old. Returns $true when the folder is out of the
# way (moved, or never existed) and $false if something still has it locked.
function Move-Aside([string]$Path) {
    if (-not (Test-Path -LiteralPath $Path)) { return $true }
    $backup = "$Path.old"
    if (Test-Path -LiteralPath $backup) {
        # Leftover from an earlier run - replace it so only one backup is kept.
        $old = Get-Item -LiteralPath $backup -Force
        Write-Host (" Replacing {0} from your last run ({1:MMM d, yyyy}, {2})" -f
                    $old.Name, $old.LastWriteTime, (Format-Size (Get-FolderSize $backup)))
        Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue
        if (Test-Path -LiteralPath $backup) {
            $backup = "$Path.old-$(Get-Date -Format yyyyMMddHHmmss)"
        }
    }
    try {
        Rename-Item -LiteralPath $Path -NewName (Split-Path $backup -Leaf) -ErrorAction Stop
        Write-Host " $(Split-Path $Path -Leaf) -> $(Split-Path $backup -Leaf)" -ForegroundColor $Theme.Good
        return $true
    } catch {
        Write-Warning " Couldn't move $(Split-Path $Path -Leaf) yet: $($_.Exception.Message)"
        return $false
    }
}

function Get-FolderSize([string]$Path) {
    [double](Get-ChildItem -LiteralPath $Path -Recurse -File -Force -ErrorAction SilentlyContinue |
             Measure-Object -Property Length -Sum).Sum
}

function Format-Size([double]$Bytes) {
    if     ($Bytes -ge 1GB) { '{0:N1} GB' -f ($Bytes / 1GB) }
    elseif ($Bytes -ge 1MB) { '{0:N0} MB' -f ($Bytes / 1MB) }
    else                    { '{0:N0} KB' -f ($Bytes / 1KB) }
}

# Finds SoftwareDistribution.old / catroot2.old, plus any date-stamped
# .old-<time> copies made when an old backup couldn't be replaced.
function Get-OldBackup {
    foreach ($folder in $cacheFolders) {
        Get-ChildItem -LiteralPath (Split-Path $folder -Parent) -Directory -Force `
                      -Filter "$(Split-Path $folder -Leaf).old*" -ErrorAction SilentlyContinue
    }
}

# Deletes the .old backups. ShouldProcess gives it -WhatIf and -Confirm, and
# it picks those up from the script when FixWU is run with them.
function Invoke-Cleanup {
    [CmdletBinding(SupportsShouldProcess)]
    param()
    $backups = @(Get-OldBackup)
    if ($backups.Count -eq 0) {
        Write-Host " No .old backups found - nothing to clean up." -ForegroundColor $Theme.Good
        return
    }
    $freed = 0
    foreach ($b in $backups) {
        $size = Get-FolderSize $b.FullName
        if (-not $PSCmdlet.ShouldProcess($b.FullName, "Delete backup ($(Format-Size $size))")) { continue }
        Remove-Item -LiteralPath $b.FullName -Recurse -Force -Confirm:$false -ErrorAction SilentlyContinue
        if (Test-Path -LiteralPath $b.FullName) {
            $failures.Add("Couldn't fully delete $($b.FullName) - some files are in use. Try again after a reboot.")
        } else {
            Write-Host " Deleted $($b.Name) ($(Format-Size $size))" -ForegroundColor $Theme.Good
            $freed += $size
        }
    }
    if ($freed -gt 0) { Write-Host " Freed $(Format-Size $freed)." -ForegroundColor $Theme.Good }
}

Write-Host "==========================================" -ForegroundColor $Theme.Frame
Write-Host " FixWU - Windows Update Reset " -ForegroundColor $Theme.Frame
Write-Host "==========================================" -ForegroundColor $Theme.Frame

# ---------------------------------------------------------------------------
# Cleanup mode: delete the .old backups from earlier resets, then stop.
# ---------------------------------------------------------------------------
if ($Cleanup) {
    Write-Step "Deleting .old backups from earlier resets..."
    Invoke-Cleanup
    Write-Host "`n==========================================" -ForegroundColor $Theme.Frame
    if ($failures.Count) {
        foreach ($f in $failures) { Write-Host " - $f" -ForegroundColor $Theme.Bad }
    } elseif ($WhatIfPreference) {
        Write-Host " Preview only (-WhatIf): nothing was deleted." -ForegroundColor $Theme.Good
    } else {
        Write-Host " Cleanup complete." -ForegroundColor $Theme.Good
    }
    Write-Host "==========================================" -ForegroundColor $Theme.Frame
    Close-Log
    if ($failures.Count) { exit 1 }
    exit 0
}

# ---------------------------------------------------------------------------
# 1. Pre-flight: look for causes that clearing caches won't fix
# ---------------------------------------------------------------------------
Write-Step "[1/5] Checking for problems a reset can't fix..."

$rebootKeys = @(
    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending',
    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
)
if ($rebootKeys | Where-Object { Test-Path $_ }) {
    if (-not $Force) {
        Write-Warning "Windows is waiting on a reboot to finish installing updates."
        Write-Warning "Reboot first and try Windows Update again - that alone often unsticks it."
        Write-Warning "Wiping the update cache now could confuse the half-finished install."
        Write-Warning "(Run with -Force to reset anyway.)"
        Close-Log
        exit 1
    }
    $cantFix.Add("A reboot was already pending when this ran - reboot before checking for updates.")
}

$wuService = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
if (-not $wuService) {
    $cantFix.Add("The Windows Update service (wuauserv) is missing entirely.")
} elseif ($wuService.StartType -eq 'Disabled') {
    $cantFix.Add("The Windows Update service is DISABLED (update-blocker tools do this). Re-enable it with: Set-Service wuauserv -StartupType Manual")
}

$policyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
$policy    = Get-ItemProperty -Path $policyKey -ErrorAction SilentlyContinue
$auPolicy  = Get-ItemProperty -Path "$policyKey\AU" -ErrorAction SilentlyContinue
if ($auPolicy.UseWUServer -eq 1 -and $policy.WUServer) {
    $cantFix.Add("Group Policy points updates at a WSUS server ($($policy.WUServer)). If that server is gone, downloads will keep failing.")
}
if ($policy.DoNotConnectToWindowsUpdateInternetLocations -eq 1) {
    $cantFix.Add("Group Policy blocks connecting to Windows Update over the internet.")
}
if ($policy.DisableWindowsUpdateAccess -eq 1) {
    $cantFix.Add("Group Policy has turned off access to Windows Update.")
}

$ux = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings' -ErrorAction SilentlyContinue
$pausedUntil = [datetime]::MinValue
if ($ux.PauseUpdatesExpiryTime -and
    [datetime]::TryParse($ux.PauseUpdatesExpiryTime, [ref]$pausedUntil) -and
    $pausedUntil -gt (Get-Date)) {
    $cantFix.Add("Updates are paused until $pausedUntil. Click 'Resume updates' in Settings > Windows Update.")
}

if ($cantFix.Count) {
    foreach ($item in $cantFix) { Write-Warning $item }
} else {
    Write-Host " Nothing found." -ForegroundColor $Theme.Good
}

# ---------------------------------------------------------------------------
# 2. Clear the download queues. BITS and Delivery Optimization need their
# services running for this, so it happens before anything is stopped.
# ---------------------------------------------------------------------------
Write-Step "[2/5] Clearing download queues..."

& "$env:SystemRoot\System32\bitsadmin.exe" /reset /allusers | Out-Null
if ($LASTEXITCODE -eq 0) {
    Write-Host " BITS queue cleared." -ForegroundColor $Theme.Good
} else {
    $failures.Add("bitsadmin /reset exited with code $LASTEXITCODE.")
}

if (Get-Command Delete-DeliveryOptimizationCache -ErrorAction SilentlyContinue) {
    try {
        # This cmdlet prints its own "Deleting..." lines with Write-Host, which
        # goes to the information stream (6), not the output that Out-Null eats.
        Delete-DeliveryOptimizationCache -Force -ErrorAction Stop 6>$null | Out-Null
        Write-Host " Delivery Optimization cache cleared." -ForegroundColor $Theme.Good
    } catch {
        $failures.Add("Couldn't clear the Delivery Optimization cache: $($_.Exception.Message)")
    }
}

# ---------------------------------------------------------------------------
# 3. Stop services and move the caches aside. Windows sometimes restarts a
# service (or grabs a file) between the stop and the rename, so retry.
# ---------------------------------------------------------------------------
Write-Step "[3/5] Stopping update services and moving caches aside..."

$remaining = $cacheFolders
for ($attempt = 1; $attempt -le 3 -and $remaining.Count -gt 0; $attempt++) {
    if ($attempt -gt 1) {
        Write-Host " Something grabbed a file - stopping services again (attempt $attempt of 3)..."
        Start-Sleep -Seconds 5
    }
    Stop-UpdateService
    $remaining = @($remaining | Where-Object { -not (Move-Aside $_) })
}
foreach ($folder in $remaining) {
    $failures.Add("Couldn't move $folder aside (a file in it stayed locked).")
}

# ---------------------------------------------------------------------------
# 4. Optional: network stack reset
# ---------------------------------------------------------------------------
if ($ResetNetwork) {
    Write-Step "[4/5] Resetting WinHTTP proxy and Winsock catalog..."
    netsh.exe winhttp reset proxy | Out-Null
    if ($LASTEXITCODE -ne 0) { $failures.Add("netsh winhttp reset proxy exited with code $LASTEXITCODE.") }
    netsh.exe winsock reset | Out-Null
    if ($LASTEXITCODE -ne 0) { $failures.Add("netsh winsock reset exited with code $LASTEXITCODE.") }
} else {
    Write-Step "[4/5] Skipping network reset (add -ResetNetwork to include it)."
}

# ---------------------------------------------------------------------------
# 5. Put services back the way they were. Ones that weren't running are
# trigger-started and will wake up on their own when needed.
# ---------------------------------------------------------------------------
Write-Step "[5/5] Restarting services..."
foreach ($name in 'cryptsvc', 'bits', 'wuauserv', 'UsoSvc') {
    if ($wasRunning -notcontains $name) { continue }
    try {
        # -WarningAction hides the harmless "Waiting for service to start..." note
        Start-Service -Name $name -ErrorAction Stop -WarningAction SilentlyContinue
        Write-Host " Started $name" -ForegroundColor $Theme.Good
    } catch {
        $failures.Add("Couldn't restart ${name}: $($_.Exception.Message)")
    }
}

# ---------------------------------------------------------------------------
# Optional: repair the component store. Runs after the restart because DISM
# downloads clean copies of damaged files through Windows Update.
# ---------------------------------------------------------------------------
if ($Repair) {
    Write-Step "Repairing system files (DISM, then SFC). This takes a while - let it finish."
    DISM.exe /Online /Cleanup-Image /RestoreHealth
    if ($LASTEXITCODE -ne 0) { $failures.Add("DISM /RestoreHealth exited with code $LASTEXITCODE.") }
    sfc.exe /scannow
}

# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
Write-Host "`n==========================================" -ForegroundColor $Theme.Frame
if ($failures.Count -eq 0) {
    Write-Host " Reset complete." -ForegroundColor $Theme.Good
} else {
    Write-Host " Reset finished with problems:" -ForegroundColor $Theme.Bad
    foreach ($f in $failures) { Write-Host " - $f" -ForegroundColor $Theme.Bad }
}
if ($cantFix.Count) {
    Write-Host "`n Still needs your attention (a reset can't fix these):" -ForegroundColor $Theme.Warn
    foreach ($c in $cantFix) { Write-Host " - $c" -ForegroundColor $Theme.Warn }
}
Write-Host "`n Next:" -ForegroundColor $Theme.Frame
if ($ResetNetwork) {
    Write-Host " - Restart your PC (the Winsock reset needs it)."
}
Write-Host " - Open Settings > Windows Update and click 'Check for updates'."
Write-Host " The first check after a reset rebuilds everything, so give it a few"
Write-Host " minutes. If Settings sits on 'Checking for updates...', close it and"
Write-Host " reopen it: the check has usually finished in the background already."
$backups = @(Get-OldBackup)
if ($backups.Count) {
    $backupSize = 0
    foreach ($b in $backups) { $backupSize += Get-FolderSize $b.FullName }
    Write-Host " - Once updates work, run FixWU with -Cleanup to delete the .old backups"
    Write-Host " and get $(Format-Size $backupSize) back."
}
Write-Host "`n Log saved to: $logPath"
Write-Host "==========================================" -ForegroundColor $Theme.Frame
Close-Log
if ($failures.Count) { exit 1 }