maik.ing | the terminal garden
Building, Breaking, and Automating.
Welcome to maik.ing. This is my digital toolbox for everything related to Microsoft 365, PowerShell automation, and cloud infrastructure. Here, I share functional scribbles, in-depth M365 insights, and the occasional Nordic breeze from my everyday IT life.
Subscribe via RSS here

When "Getting Windows Ready" Becomes a Nightmare – and TrustedInstaller Is the Villain

Tags: hyper-v windows-server troubleshooting trustedinstaller windows-update


It started like a completely normal evening. A Windows Server 2016 VM sitting on Hyper-V, spinning away at the "Getting Windows Ready" screen – for almost two hours. What followed was a deep dive through checkpoints, corrupted VHDX chains, registry hives, and finally one single command that solved everything.

Here's the full story.


The Setup

  • Host: Hyper-V on Windows Server
  • Guest: Windows Server 2016 VM
  • Symptom: Stuck on "Getting Windows Ready" after a cumulative Windows Update – for nearly 2 hours

Phase 1 – The Obvious Steps

First instinct: wait it out. HDD activity can spike during large updates, especially on VMs with spinning disks or limited I/O. But after two hours with no progress, it was time to act.

A hard reset was attempted. Then another. Both times, Windows booted straight back into the same screen – a classic update loop.

The loop happens when Windows Update partially applies a patch, sets pending operations in the registry or filesystem, and then fails silently on every reboot. Windows keeps trying. Windows keeps failing. You keep staring at the same screen.


Phase 2 – Mounting the VHDX Directly

Since the VM was unreachable via RDP and WinRE wasn't triggering automatically, the plan was to mount the VHDX directly from the Hyper-V host and clean up the update cache manually.

Mount-VHD -Path "D:\Hyper-V\VMNAME\Virtual Hard Disks\vmname-disk-c.vhdx" -ReadWrite

The target: rename the SoftwareDistribution folder to break the update loop.

ren E:\Windows\SoftwareDistribution SoftwareDistribution.old

✅ Done. VHDX dismounted. VM started.

Result: Still stuck on "Getting Windows Ready."


Phase 3 – The VHDX Chain Breaks

Here's where things got interesting – and slightly nerve-wracking.

It turned out the VM had an existing checkpoint (.avhdx differencing disk). By mounting the parent VHDX directly, the parent-child relationship between the base disk and the differencing disk was broken. Hyper-V tracks this relationship using internal identifiers, and mounting the parent externally caused an ID mismatch.

The error on next start:

The chain of virtual hard disks is corrupted. 
There is a mismatch in the identifiers of the parent virtual hard disk
and differencing disk.

Not great. But fixable.

The Fix: Set-VHD -IgnoreIdMismatch

Set-VHD `
-Path "D:\Hyper-V\VMNAME\Virtual Hard Disks\vmname-disk-c_<GUID>.avhdx" `
-ParentPath "D:\Hyper-V\VMNAME\Virtual Hard Disks\vmname-disk-c.vhdx" `
-IgnoreIdMismatch

This command rewrites only the metadata reference in the differencing disk – no data is moved, nothing is overwritten. It's the surgical fix for exactly this scenario.

⚠️ Lesson learned: Never mount a parent VHDX directly when the VM has active checkpoints. Always merge or delete checkpoints first – or use Mount-VHD on the differencing disk itself.

VM started again. Still "Getting Windows Ready." Back to square one, but at least the chain was intact.


Phase 4 – WinRE and the Registry Hunt

With SoftwareDistribution already renamed and the VHDX chain repaired, the next target was the registry. The goal: find and remove any pending operation flags that were keeping Windows in the loop.

WinRE was triggered using the 3x hard reset method – forcefully shutting down the VM three times during the boot sequence until Windows automatically drops into the recovery environment.

In the WinRE command prompt, the SYSTEM and SOFTWARE hives were loaded manually:

reg load HKLM\TEMPSOFT C:\Windows\System32\config\SOFTWARE

reg query "HKLM\TEMPSOFT\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"
reg query "HKLM\TEMPSOFT\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired" reg unload HKLM\TEMPSOFT

Note: In WinRE, the Windows partition is often not C:\ – always verify with dir C:\Windows, dir D:\Windows, etc. first.

Neither key existed. No PendingFileRenameOperations either. The registry was clean.

The update loop had no obvious flag driving it. Something else was keeping Windows stuck.


Phase 5 – The Real Culprit: TrustedInstaller

Here's the twist nobody expects.

The VM was still reachable on the network during the "Getting Windows Ready" phase. A quick check revealed that TrustedInstaller.exe – the Windows Modules Installer, responsible for applying updates – was still running as a process and had simply hung. It wasn't crashing, it wasn't erroring out. It was just... stuck. And Windows was politely waiting for it to finish before proceeding with the boot.

The fix was one command, executed remotely from another machine:

taskkill /S 10.x.x.x /U Administrator /P ******** /IM trustedinstaller.exe

Windows immediately continued booting. Server came up clean.


Root Cause Summary

Stage

Finding

Initial symptom

TrustedInstaller.exe hung during update application

Why looping

Process still alive across reboots, Windows waited for it

SoftwareDistribution rename

Didn't help – process was the issue, not the cache

VHDX mount

Caused ID mismatch due to existing checkpoint

Registry check

No pending flags found

Actual fix

Remote taskkill of trustedinstaller.exe


Key Takeaways

1. Check network connectivity first If the VM is still reachable during "Getting Windows Ready", you have more options than you think. taskkill /S is a powerful remote tool.

2. Never mount a parent VHDX with active checkpoints Use Set-VHD -IgnoreIdMismatch to recover, but avoid it in the first place.

3. TrustedInstaller can hang silently No event log entry, no crash dump, no obvious sign – it just sits there. If you're stuck in a "Getting Windows Ready" loop with no registry flags and a clean SoftwareDistribution, check whether TrustedInstaller.exe is still alive.

4. Always back up before touching registry or VHDX

Copy-Item "vmname-disk-c.vhdx" "vmname-disk-c.vhdx.bak"
reg export "HKLM\TEMP\ControlSet001\Control\Session Manager" C:\backup.reg

Two hours of troubleshooting. One taskkill. Sometimes IT is exactly like that.

Recursively Get All Members of an Exchange Online Distribution List (Including Dynamic & M365 Groups)

Getting a complete, flat list of end-users from a massive, nested distribution list in Exchange Online can be surprisingly frustrating.

If you try to use the standard Get-DistributionGroupMember cmdlet, you will quickly notice two things:

  1. It lacks a native -Recursive parameter.
  2. If your main distribution list contains nested Microsoft 365 Groups (Unified Groups) or Dynamic Distribution Groups, a simple recursive loop will crash and flood your console with red ManagementObjectNotFoundException errors.

Different group types in Exchange Online require completely different cmdlets. Here is a robust PowerShell function that dynamically identifies the group type, uses the correct cmdlet to fetch its members, and gracefully skips broken or orphaned objects without crashing.

The PowerShell Script

Copy and paste this function into your Exchange Online PowerShell session:

PowerShell

Function Get-DistributionGroupMemberRecursive {
param (
[Parameter(Mandatory=$true)]
[string]$Identity,
[string]$GroupType = "DistributionGroup" # Default starting type
) $Members = $null try {
# 1. Select the correct cmdlet based on the group type
if ($GroupType -match "GroupMailbox|UnifiedGroup") {
# For Microsoft 365 Groups
$Members = Get-UnifiedGroupLinks -Identity $Identity -LinkType Members -ErrorAction Stop
}
elseif ($GroupType -match "DynamicDistributionGroup") {
# For Dynamic Distribution Groups
$Members = Get-DynamicDistributionGroupMember -Identity $Identity -ErrorAction Stop
}
else {
# For Classic Distribution Lists / Security Groups
$Members = Get-DistributionGroupMember -Identity $Identity -ResultSize Unlimited -ErrorAction Stop
}
}
catch {
# Gracefully handle orphaned objects or resolution errors
Write-Warning "Failed to read group '$Identity' ($GroupType). It may be an orphaned object."
return
} # 2. Iterate through members and evaluate
if ($null -ne $Members) {
foreach ($Member in $Members) {

$MemberType = $Member.RecipientTypeDetails # Check if the member is a nested group and recurse accordingly
if ($MemberType -match "GroupMailbox|UnifiedGroup") {
Get-DistributionGroupMemberRecursive -Identity $Member.PrimarySmtpAddress -GroupType "GroupMailbox"
}
elseif ($MemberType -match "DynamicDistributionGroup") {
Get-DistributionGroupMemberRecursive -Identity $Member.PrimarySmtpAddress -GroupType "DynamicDistributionGroup"
}
elseif ($MemberType -match "Group") {
Get-DistributionGroupMemberRecursive -Identity $Member.PrimarySmtpAddress -GroupType "DistributionGroup"
}
else {
# Output the actual end-user (UserMailbox, SharedMailbox, MailContact, etc.)
$Member | Select-Object Name, PrimarySmtpAddress, RecipientTypeDetails
}
}
}
}

Why This Approach Works

  • Smart Cmdlet Switching: The script checks the RecipientTypeDetails of every nested group. It seamlessly switches to Get-UnifiedGroupLinks for M365 Groups and Get-DynamicDistributionGroupMember when it needs to calculate dynamic rule-based memberships.
  • Error Handling: Real-world Active Directories are messy. The try/catch block ensures that if a nested group contains a deleted user or an orphaned SID, the script throws a gentle yellow warning instead of a fatal error, allowing the rest of the extraction to continue.

How to Use and Export

Once the function is loaded into your session, you can run it against your parent group. To make the output useful, you can pipe the results directly into a CSV file.

Note: Because users might be members of multiple nested groups, adding Select-Object -Unique is highly recommended to filter out duplicates.

PowerShell

# Run the script and export a clean, deduplicated list to a CSV file
Get-DistributionGroupMemberRecursive -Identity "company.news@yourdomain.com" |
Select-Object Name, PrimarySmtpAddress, RecipientTypeDetails -Unique |
Export-Csv -Path "C:\temp\CompanyNews_Members.csv" -NoTypeInformation -Encoding UTF8

🛠️ Exchange Online: The "Disabled" Flag Bug After Mailbox Conversion

Have you recently converted a Shared Mailbox to a Regular User Mailbox, assigned a license, and yet OWA keeps crashing on login?

Error: AccountTerminationException | st: 440

Symptom: SyntaxError: JSON.parse: unexpected end of data

Even though the Microsoft 365 Admin Center shows everything as "Healthy," the mailbox is stuck in a ghost state. Here is how to fix the Disabled Flag Bug using PowerShell.


The Core Problem: Why Login Fails

By design, Shared Mailboxes are user accounts where direct login is disabled. When you convert a mailbox to Regular, Exchange updates the mailbox type but often "forgets" to flip the sign-in flag in the underlying Microsoft Entra (Azure AD) identity.

The result: The mailbox exists and the license is active, but the server kills the authentication mid-stream because it still thinks the user isn't allowed to log in.


The Solution: The PowerShell Fix

To resolve this, we must manually force the AccountDisabled attribute to False.

1. Verify the Status

Connect to Exchange Online and check what the system actually thinks of the account:

PowerShell

Get-User -Identity "info@yourdomain.com" | Select-Object Name, RecipientTypeDetails, AccountDisabled

If AccountDisabled returns True, you’ve found your culprit.

2. Force the Login to Enable

Run the following command to lift the restriction:

PowerShell

Set-Mailbox -Identity "info@yourdomain.com" -AccountDisabled $false

3. Sync the Identity via Microsoft Graph

If OWA still throws the 440 error after the command above, the user object itself must be enabled in the Microsoft 365 directory:

PowerShell

# Requires the Microsoft.Graph module
Update-MgUser -UserId "info@yourdomain.com" -AccountEnabled $true

Troubleshooting Checklist

If your conversion is still stuck, work through this list:

  1. Licensing: Is an Exchange Online license (Plan 1/2 or Business) actually assigned?
  2. Password: Was a new password set after the conversion?
  3. AccountEnabled: Is the flag set to $true (or AccountDisabled $false) via PowerShell?
  4. Browser Cache: Test in Incognito Mode. This is critical. OWA aggressively caches "Shared Mailbox" session tokens, which will trigger the JSON error even if the backend is fixed.

Written for admins who don't have time to wait 24 hours for "Replication."

🚀 Scripting Bulk Mailbox Delegation in Exchange Online

When managing a growing team, you often find yourself in a situation where a lead user (e.g., a department head or administrator) needs access to multiple shared or functional mailboxes.

Manually clicking through the Microsoft 365 Admin Center for 10+ mailboxes is a recipe for boredom and typos. Here is how I solved this using the Exchange Online PowerShell V3 module.

The Scenario

We need to grant one primary user (User A) two specific types of permissions across a list of target mailboxes:

  1. FullAccess: The ability to open the mailbox, read, and organize emails.
  2. SendAs: The ability to send emails appearing as the target mailbox address.

The PowerShell Solution

First, ensure you are connected:

Connect-ExchangeOnline

Then, run this script to loop through your targets:

PowerShell

# 1. Define the Lead User (The one receiving the permissions)
$LeadUser = "primary.user@company-it.com" # 2. Define the list of target mailboxes (Shared or User mailboxes)
$TargetMailboxes = @(
"info@company-it.com",
"accounting@company-it.com",
"logistics@company-it.com",
"project-alpha@company-it.com",
"support@company-it.com",
"hr-dept@company-it.com"
) # 3. Apply permissions via loop
foreach ($Mailbox in $TargetMailboxes) {
Write-Host "Processing: $Mailbox" -ForegroundColor Cyan

# Grant Full Access (includes Auto-Mapping for Outlook Desktop)
Add-MailboxPermission -Identity $Mailbox -User $LeadUser -AccessRights FullAccess -InheritanceType All -Confirm:$false

# Grant Send As permissions
Add-RecipientPermission -Identity $Mailbox -Trustee $LeadUser -AccessRights SendAs -Confirm:$false
} Write-Host "Success: All permissions applied." -ForegroundColor Green
powered by Scribbles