-
Notifications
You must be signed in to change notification settings - Fork 8.2k
Enhance MSI registration scripts with idempotency and timeout handling; add tests for registration scripts #26740
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
10206c1
a833f1d
ea26e2a
0dfa72d
8f5b771
81713c1
e253ca1
74d6583
c589f28
477e123
8b69fbd
e2061c7
3139cc4
e148d2c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,12 +1,78 @@ | ||||||||||||||
| #Requires -Version 7.0 | ||||||||||||||
|
|
||||||||||||||
| # Copyright (c) Microsoft Corporation. | ||||||||||||||
| # Licensed under the MIT License. | ||||||||||||||
|
|
||||||||||||||
| <# | ||||||||||||||
| .SYNOPSIS | ||||||||||||||
| Registers Microsoft Update for automatic updates (best-effort, non-blocking). | ||||||||||||||
|
|
||||||||||||||
| .DESCRIPTION | ||||||||||||||
| This script is called by the MSI installer to opt into Microsoft Update. | ||||||||||||||
| It is designed to be: | ||||||||||||||
| - Idempotent: exits early if already registered | ||||||||||||||
| - Time-bounded: uses external process with timeout to avoid hangs | ||||||||||||||
| - Non-fatal: always exits 0 so the MSI can complete | ||||||||||||||
|
|
||||||||||||||
| In constrained language mode or WDAC/AppLocker environments, COM operations | ||||||||||||||
| may fail. This script handles those cases gracefully. | ||||||||||||||
|
|
||||||||||||||
| .PARAMETER TestHook | ||||||||||||||
| For testing purposes only. 'Hang' simulates a hang, 'Fail' simulates a failure. | ||||||||||||||
| #> | ||||||||||||||
|
|
||||||||||||||
| param( | ||||||||||||||
| [ValidateSet('Hang', 'Fail')] | ||||||||||||||
| $TestHook | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| $waitTimeoutSeconds = 300 | ||||||||||||||
| # Microsoft Update service GUID | ||||||||||||||
| $MicrosoftUpdateServiceId = '7971f918-a847-4430-9279-4a52d1efe18d' | ||||||||||||||
| # Service registration flags: asfAllowPendingRegistration + asfAllowOnlineRegistration + asfRegisterServiceWithAU | ||||||||||||||
| $MicrosoftUpdateServiceRegistrationFlags = 7 | ||||||||||||||
| $waitTimeoutSeconds = 120 | ||||||||||||||
|
|
||||||||||||||
| # Helper function to release COM objects deterministically | ||||||||||||||
| function Release-ComObject { | ||||||||||||||
| param($ComObject) | ||||||||||||||
| if ($null -ne $ComObject) { | ||||||||||||||
| try { | ||||||||||||||
| [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($ComObject) | ||||||||||||||
| } | ||||||||||||||
| catch { | ||||||||||||||
| # Intentionally suppressing errors - COM release can fail if object is already released | ||||||||||||||
| # or inaccessible. This is expected and should not affect script success. | ||||||||||||||
| Write-Verbose "COM object cleanup failed (expected/safe to ignore): $_" -Verbose:$false | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| # Idempotent pre-check: if already registered, exit immediately | ||||||||||||||
| # This runs outside the job/external process to minimize work when already registered | ||||||||||||||
| function Test-MicrosoftUpdateRegistered { | ||||||||||||||
| $serviceManager = $null | ||||||||||||||
| $registration = $null | ||||||||||||||
| $service = $null | ||||||||||||||
| try { | ||||||||||||||
| $serviceManager = New-Object -ComObject Microsoft.Update.ServiceManager | ||||||||||||||
| $registration = $serviceManager.QueryServiceRegistration($MicrosoftUpdateServiceId) | ||||||||||||||
| $service = $registration.Service | ||||||||||||||
| return $service.IsRegisteredWithAu | ||||||||||||||
| } | ||||||||||||||
| catch { | ||||||||||||||
| # QueryServiceRegistration throws if the service isn't registered | ||||||||||||||
| # or if COM operations fail. In either case, we should attempt registration. | ||||||||||||||
| # Return $false to indicate not registered. | ||||||||||||||
| return $false | ||||||||||||||
| } | ||||||||||||||
| finally { | ||||||||||||||
| Release-ComObject $service | ||||||||||||||
| Release-ComObject $registration | ||||||||||||||
| Release-ComObject $serviceManager | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| # Build the job script (check test hooks first before idempotency check) | ||||||||||||||
| switch ($TestHook) { | ||||||||||||||
| 'Hang' { | ||||||||||||||
| $waitTimeoutSeconds = 10 | ||||||||||||||
|
|
@@ -16,55 +82,87 @@ switch ($TestHook) { | |||||||||||||
| $jobScript = { throw "This job script should fail" } | ||||||||||||||
| } | ||||||||||||||
| default { | ||||||||||||||
| $jobScript = { | ||||||||||||||
| # This registers Microsoft Update via a predefined GUID with the Windows Update Agent. | ||||||||||||||
| # https://learn.microsoft.com/windows/win32/wua_sdk/opt-in-to-microsoft-update | ||||||||||||||
| # Check if already registered before doing any expensive work | ||||||||||||||
| Write-Verbose "MicrosoftUpdate: checking if already registered..." -Verbose | ||||||||||||||
| $alreadyRegistered = Test-MicrosoftUpdateRegistered | ||||||||||||||
|
|
||||||||||||||
| if ($alreadyRegistered -eq $true) { | ||||||||||||||
| Write-Verbose "MicrosoftUpdate: Microsoft Update is already registered, skipping" -Verbose | ||||||||||||||
| exit 0 | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| $serviceManager = (New-Object -ComObject Microsoft.Update.ServiceManager) | ||||||||||||||
| $isRegistered = $serviceManager.QueryServiceRegistration('7971f918-a847-4430-9279-4a52d1efe18d').Service.IsRegisteredWithAu | ||||||||||||||
| # Not registered, attempt to register using an external process with timeout | ||||||||||||||
| # Using external process avoids issues with constrained language mode affecting jobs/runspaces | ||||||||||||||
| Write-Verbose "MicrosoftUpdate: Microsoft Update not registered, attempting registration..." -Verbose | ||||||||||||||
|
|
||||||||||||||
| if (!$isRegistered) { | ||||||||||||||
| Write-Verbose -Verbose "Opting into Microsoft Update as the Automatic Update Service" | ||||||||||||||
| # 7 is the combination of asfAllowPendingRegistration, asfAllowOnlineRegistration, asfRegisterServiceWithAU | ||||||||||||||
| # AU means Automatic Updates | ||||||||||||||
| $null = $serviceManager.AddService2('7971f918-a847-4430-9279-4a52d1efe18d', 7, '') | ||||||||||||||
| # Normal path: register via COM in a job with timeout | ||||||||||||||
| $jobScript = { | ||||||||||||||
| param($ServiceId, $RegistrationFlags) | ||||||||||||||
| $serviceManager = New-Object -ComObject Microsoft.Update.ServiceManager | ||||||||||||||
| try { | ||||||||||||||
| $null = $serviceManager.AddService2($ServiceId, $RegistrationFlags, '') | ||||||||||||||
| Write-Host 'MicrosoftUpdate: registration succeeded' | ||||||||||||||
| return $true | ||||||||||||||
| } | ||||||||||||||
| else { | ||||||||||||||
| Write-Verbose -Verbose "Microsoft Update is already registered for Automatic Updates" | ||||||||||||||
| catch { | ||||||||||||||
| Write-Host "MicrosoftUpdate: registration failed - $_" | ||||||||||||||
| return $false | ||||||||||||||
| } | ||||||||||||||
| finally { | ||||||||||||||
| if ($null -ne $serviceManager) { | ||||||||||||||
| try { | ||||||||||||||
| [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($serviceManager) | ||||||||||||||
| } | ||||||||||||||
| catch { | ||||||||||||||
| # Intentionally suppressing errors - COM cleanup failures are expected and safe | ||||||||||||||
| Write-Verbose "COM cleanup failed (expected/safe to ignore): $_" -Verbose:$false | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| $isRegistered = $serviceManager.QueryServiceRegistration('7971f918-a847-4430-9279-4a52d1efe18d').Service.IsRegisteredWithAu | ||||||||||||||
|
|
||||||||||||||
| # Return if it was successful, which is the opposite of Pending. | ||||||||||||||
| return $isRegistered | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| Write-Verbose "Running job script: $jobScript" -Verbose | ||||||||||||||
| $job = Start-ThreadJob -ScriptBlock $jobScript | ||||||||||||||
| try { | ||||||||||||||
| Write-Verbose "MicrosoftUpdate: starting registration with $waitTimeoutSeconds second timeout" -Verbose | ||||||||||||||
|
|
||||||||||||||
| # Start the job | ||||||||||||||
| if ($TestHook) { | ||||||||||||||
| $job = Start-ThreadJob -ScriptBlock $jobScript | ||||||||||||||
| } | ||||||||||||||
| else { | ||||||||||||||
| $job = Start-ThreadJob -ScriptBlock $jobScript -ArgumentList $MicrosoftUpdateServiceId, $MicrosoftUpdateServiceRegistrationFlags | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| # Wait with timeout | ||||||||||||||
| $completed = Wait-Job -Job $job -Timeout $waitTimeoutSeconds | ||||||||||||||
|
|
||||||||||||||
| Write-Verbose "Waiting on Job for $waitTimeoutSeconds seconds" -Verbose | ||||||||||||||
| $null = Wait-Job -Job $job -Timeout $waitTimeoutSeconds | ||||||||||||||
| if ($completed) { | ||||||||||||||
| $result = Receive-Job -Job $job | ||||||||||||||
| Remove-Job -Job $job -Force | ||||||||||||||
|
|
||||||||||||||
| if ($job.State -ne 'Running') { | ||||||||||||||
| Write-Verbose "Job finished. State: $($job.State)" -Verbose | ||||||||||||||
| $result = Receive-Job -Job $job -Verbose | ||||||||||||||
| Write-Verbose "Result: $result" -Verbose | ||||||||||||||
| if ($result) { | ||||||||||||||
| Write-Verbose "Registration succeeded" -Verbose | ||||||||||||||
| exit 0 | ||||||||||||||
| if ($result -eq $true) { | ||||||||||||||
| Write-Verbose "MicrosoftUpdate: completed successfully" -Verbose | ||||||||||||||
| } | ||||||||||||||
| else { | ||||||||||||||
| Write-Verbose "MicrosoftUpdate: registration failed, continuing installation" -Verbose | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| else { | ||||||||||||||
| Write-Verbose "Registration failed" -Verbose | ||||||||||||||
| # at the time this was written, the MSI is ignoring the exit code | ||||||||||||||
| exit 1 | ||||||||||||||
| # Process timed out - stop the job and continue | ||||||||||||||
| Write-Verbose "MicrosoftUpdate: timed out after $waitTimeoutSeconds seconds, continuing installation" -Verbose | ||||||||||||||
| Stop-Job -Job $job | ||||||||||||||
| Remove-Job -Job $job -Force | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| else { | ||||||||||||||
| Write-Verbose "Job timed out" -Verbose | ||||||||||||||
| Write-Verbose "Stopping Job. State: $($job.State)" -Verbose | ||||||||||||||
| Stop-Job -Job $job | ||||||||||||||
| # at the time this was written, the MSI is ignoring the exit code | ||||||||||||||
| exit 258 | ||||||||||||||
| catch { | ||||||||||||||
|
||||||||||||||
| catch { | |
| catch { | |
| if ($job) { | |
| Stop-Job -Job $job -ErrorAction SilentlyContinue | |
| Remove-Job -Job $job -Force -ErrorAction SilentlyContinue | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential compatibility issue:
Start-ThreadJobis used without checking if the module is available. While ThreadJob ships with PowerShell 7+, if this script runs in Windows PowerShell 5.1 or in older PowerShell Core versions during MSI installation, it could fail. Consider adding a check for cmdlet availability or documenting the minimum PowerShell version requirement.