Improve AVD User Experience: Warm-Up to decreas User login time

If you are running Azure Virtual Desktop with FSLogix, the first login to a host takes longer than the following logins. I also wrote about this effect (I call is Cold vs. Warm start) a few weeks ago here: https://lnkd.in/eEQHP5gA and https://www.linkedin.com/posts/marcelmeurer_azurevirtualdesktop-coldstart-warmstart-activity-7227298731124019200--nsM

In short, If a VM is started in Azure, the first login of a user takes much longer than the login of the following users to this VM. And: It makes no difference if the first user logs in one or 30 minutes after starting the VM. I did a lot of tests by running ProcMon, FileActivityWatch, and RegistryChangesView in the system context to capture events during the first login 🤓. I can clearly see dependencies to AppX packages on the VM. In my tests, processing of AppX packages for the first user took 120 seconds, and for the next user, only a few seconds. Right now, I cannot reproduce the first AppX processing without a user logging into the host (but I’m working on that). The first user will also see a black screen during the login.

Therefore, I did another test: Create a temporary local user on the VM directly after the start and make a login (autogenerated passwords and direct deletion of the users after the login).

The result is as expected: After the start of the VM, the warmup user triggers the AppX processing and other “initializations.” The next - regular - user experiences a much faster login while that is technically the second login for the VM.

I wrote a script to automate a local logon after each VM/host’s start. That may not be the best way to handle this solution, but it is a workaround until I find a more pragmatic solution without a warm-up user.

How does it work? Link

The script is started directly after the start of a VM/host and runs in the context of the computer. The following steps happen:

  • Temporarily, allow RDS logon without validation computer certificates (only for the system account)
  • Generate a local user (warm-up user) with a random password. The account automatically expires after 10 minutes
  • Add the user to the Remote Desktop User Group
  • Configure the sign-on via RDS to localhost
  • The warm-up user connects via RDS to localhost (the first user logon)
  • Wait 5 minutes
  • Logoff the user
  • Remove the user account
  • Remove the user profile from disk

There are some additional error-handling routines in the script.

Pro tip: If you ever search for a way to get session information with PowerShell without parsing qwinsta.exe or quser.exe, check the script. It also contains a PowerShell method to log off a specific session.

Install the warm-up user script Link

You can install the script on an existing host or on your Golden Master / Template VM (which you hopefully capture loss-less with WVDAdmin or Hydra for Azure Virtual Desktop). Copy the script to the VM and run the following command in an administrative PowerShell:

.\WarmUp.ps1 -Mode Install

This copies the script to C:\windows\WarmUp.ps1 and creates a scheduled task to start the script automatically with the computer.

The script Link

Save it as WarmUp.ps1 or download it from here: WarmUp.ps1

param(
	[string] $Mode
)

Add-Type -Language CSharp -TypeDefinition @'
using System;
using System.Linq;
using System.Runtime.InteropServices;
using System.Collections.Generic;
namespace Rds {
    public static class Sessions {
        [DllImport("wtsapi32.dll")]
        static extern IntPtr WTSOpenServer([MarshalAs(UnmanagedType.LPStr)] String pServerName);
        [DllImport("wtsapi32.dll")]
        static extern void WTSCloseServer(IntPtr hServer);
        [DllImport("wtsapi32.dll")]
        static extern Int32 WTSEnumerateSessions(IntPtr hServer,
            [MarshalAs(UnmanagedType.U4)] Int32 Reserved,
            [MarshalAs(UnmanagedType.U4)] Int32 Version, ref IntPtr ppSessionInfo,
            [MarshalAs(UnmanagedType.U4)] ref Int32 pCount);
        [DllImport("wtsapi32.dll")]
        static extern void WTSFreeMemory(IntPtr pMemory);
        [DllImport("Wtsapi32.dll")]
        static extern bool WTSQuerySessionInformation(System.IntPtr hServer, int sessionId, WTS_INFO_CLASS wtsInfoClass, out System.IntPtr ppBuffer, out uint pBytesReturned);
        [DllImport("Wtsapi32.dll")]
        static extern bool WTSLogoffSession(System.IntPtr hServer, int sessionId, bool wait);
        [StructLayout(LayoutKind.Sequential)]
        private struct WTS_SESSION_INFO {
            public Int32 SessionID;
            [MarshalAs(UnmanagedType.LPStr)]
            public String pWinStationName;
            public WtsConnect State;
        }
        public enum WTS_INFO_CLASS {
            WTSInitialProgram,
            WTSApplicationName,
            WTSWorkingDirectory,
            WTSOEMId,
            WTSSessionId,
            WTSUserName,
            WTSWinStationName,
            WTSDomainName,
            WTSConnectState,
            WTSClientBuildNumber,
            WTSClientName,
            WTSClientDirectory,
            WTSClientProductId,
            WTSClientHardwareId,
            WTSClientAddress,
            WTSClientDisplay,
            WTSClientProtocolType,
            WTSIdleTime,
            WTSLogonTime,
            WTSIncomingBytes,
            WTSOutgoingBytes,
            WTSIncomingFrames,
            WTSOutgoingFrames,
            WTSClientInfo,
            WTSSessionInfo,
            WTSSessionInfoEx,
            WTSConfigInfo,
            WTSValidationInfo,
            WTSSessionAddressV4,
            WTSIsRemoteSession
        }
        public enum WtsConnect {
            Active,
            Connected,
            ConnectQuery,
            Shadow,
            Disconnected,
            Idle,
            Listen,
            Reset,
            Down,
            Init
        }
        public class WtsSessionInformation {
            public string InitialProgram;
            public string ApplicationName;
            public string WorkingDirectory;
            public string OEMId;
            public int SessionId;
            public string UserName;
            public string WinStationName;
            public string DomainName;
            public WtsConnect ConnectState;
            public string ConnectStateString;
            public int ClientBuildNumber;
            public string ClientName;
            public string ClientDirectory;
        }
        public static IntPtr OpenServer(String name) {
            IntPtr server = WTSOpenServer(name);
            return server;
        }
        public static void CloseServer(IntPtr serverHandle) {
            WTSCloseServer(serverHandle);
        }
        public static bool LogoffSession(int id, bool waitForLogoff) {
            IntPtr serverHandle = IntPtr.Zero;
            serverHandle = OpenServer(Environment.MachineName);
            IntPtr sessionInfoPtr = IntPtr.Zero;
            try {
                return WTSLogoffSession(serverHandle, id, waitForLogoff);
            } catch (Exception e) {
                Console.WriteLine("Exception querying session information: " + e.ToString());
            } finally {
                WTSFreeMemory(sessionInfoPtr);
                CloseServer(serverHandle);
            }
            return false;
        }
        public static WtsSessionInformation GetSession(string userName) {
            var sessions=GetSessions();
            return sessions.FirstOrDefault(n=>string.Equals(n.UserName, userName, StringComparison.InvariantCultureIgnoreCase));
            
        }
        public static List < WtsSessionInformation > GetSessions() {
            var wtsList = new List < WtsSessionInformation > ();
            IntPtr serverHandle = IntPtr.Zero;
            serverHandle = OpenServer(Environment.MachineName);
            IntPtr sessionInfoPtr = IntPtr.Zero;
            try {
                IntPtr sPtr = IntPtr.Zero;
                Int32 sessionCount = 0;
                Int32 retVal = WTSEnumerateSessions(serverHandle, 0, 1, ref sessionInfoPtr, ref sessionCount);
                Int32 dataSize = Marshal.SizeOf(typeof(WTS_SESSION_INFO));
                Int64 currentSession = (long) sessionInfoPtr;
                uint bytes = 0;
                if (retVal != 0) {
                    for (int i = 0; i < sessionCount; i++) {
                        WTS_SESSION_INFO si = (WTS_SESSION_INFO) Marshal.PtrToStructure((System.IntPtr) currentSession, typeof(WTS_SESSION_INFO));
                        currentSession += dataSize;
                        var wts = new WtsSessionInformation();
                        if (WTSQuerySessionInformation(serverHandle, si.SessionID, WTS_INFO_CLASS.WTSUserName, out sPtr, out bytes)) {
                            wts.UserName = Marshal.PtrToStringAnsi(sPtr);
                            WTSFreeMemory(sPtr);
                        }
                        if (WTSQuerySessionInformation(serverHandle, si.SessionID, WTS_INFO_CLASS.WTSDomainName, out sPtr, out bytes)) {
                            wts.DomainName = Marshal.PtrToStringAnsi(sPtr);
                            WTSFreeMemory(sPtr);
                        }
                        if (WTSQuerySessionInformation(serverHandle, si.SessionID, WTS_INFO_CLASS.WTSWinStationName, out sPtr, out bytes)) {
                            wts.WinStationName = Marshal.PtrToStringAnsi(sPtr);
                            WTSFreeMemory(sPtr);
                        }
                        if (WTSQuerySessionInformation(serverHandle, si.SessionID, WTS_INFO_CLASS.WTSSessionId, out sPtr, out bytes)) {
                            wts.SessionId = Marshal.ReadInt32(sPtr);
                            WTSFreeMemory(sPtr);
                        }
                        if (WTSQuerySessionInformation(serverHandle, si.SessionID, WTS_INFO_CLASS.WTSInitialProgram, out sPtr, out bytes)) {
                            wts.InitialProgram = Marshal.PtrToStringAnsi(sPtr);
                            WTSFreeMemory(sPtr);
                        }
                        if (WTSQuerySessionInformation(serverHandle, si.SessionID, WTS_INFO_CLASS.WTSApplicationName, out sPtr, out bytes)) {
                            wts.ApplicationName = Marshal.PtrToStringAnsi(sPtr);
                            WTSFreeMemory(sPtr);
                        }
                        if (WTSQuerySessionInformation(serverHandle, si.SessionID, WTS_INFO_CLASS.WTSWorkingDirectory, out sPtr, out bytes)) {
                            wts.WorkingDirectory = Marshal.PtrToStringAnsi(sPtr);
                            WTSFreeMemory(sPtr);
                        }
                        if (WTSQuerySessionInformation(serverHandle, si.SessionID, WTS_INFO_CLASS.WTSConnectState, out sPtr, out bytes)) {
                            wts.ConnectState = (WtsConnect) Marshal.ReadInt32(sPtr);
                            wts.ConnectStateString = wts.ConnectState.ToString();
                            WTSFreeMemory(sPtr);
                        }
                        if (WTSQuerySessionInformation(serverHandle, si.SessionID, WTS_INFO_CLASS.WTSClientBuildNumber, out sPtr, out bytes)) {
                            wts.ClientBuildNumber = Marshal.ReadInt32(sPtr);
                            WTSFreeMemory(sPtr);
                        }
                        if (WTSQuerySessionInformation(serverHandle, si.SessionID, WTS_INFO_CLASS.WTSClientName, out sPtr, out bytes)) {
                            wts.ClientName = Marshal.PtrToStringAnsi(sPtr);
                            WTSFreeMemory(sPtr);
                        }
                        if (WTSQuerySessionInformation(serverHandle, si.SessionID, WTS_INFO_CLASS.WTSClientDirectory, out sPtr, out bytes)) {
                            wts.ClientDirectory = Marshal.PtrToStringAnsi(sPtr);
                            WTSFreeMemory(sPtr);
                        }
                        if (!string.IsNullOrEmpty(wts.UserName)) wtsList.Add(wts);
                    }
                }
                return wtsList;
            } catch (Exception e) {
                Console.WriteLine("Exception querying session information: " + e.ToString());
            } finally {
                WTSFreeMemory(sessionInfoPtr);
                CloseServer(serverHandle);
            }
            return null;
        }
    }
}
'@

$LogDir="$($env:windir)\system32\LogFiles"
$LogFile="$($LogDir)\AVD.WarmUp.log"
function LogWriter($message) {
	$message = "$(Get-Date ([datetime]::UtcNow) -Format "o") $message"
	write-host($message)
	if ([System.IO.Directory]::Exists($LogDir)) { try { write-output($message) | Out-File $LogFile -Append } catch {} }
}

if ($mode -like "install") {
    LogWriter("Copy script to windir")
    Copy-Item "$($MyInvocation.InvocationName)" -Destination ($env:windir + "\WarmUp.ps1") 

    LogWriter("Creating schedule task to run the script at startup")
    $action = New-ScheduledTaskAction -Execute "$env:windir\System32\WindowsPowerShell\v1.0\Powershell.exe" -Argument "-executionPolicy Unrestricted -File `"$($env:windir)\WarmUp.ps1`""
    $trigger = New-ScheduledTaskTrigger	-AtStartup
    $principal = New-ScheduledTaskPrincipal 'NT Authority\SYSTEM' -RunLevel Highest
    $settingsSet = New-ScheduledTaskSettingsSet
    $task = New-ScheduledTask -Action $action -Principal $principal -Trigger $trigger -Settings $settingsSet 
    Register-ScheduledTask -TaskName 'ITPC-AVD-WarmUpUser' -InputObject $task -ErrorAction Ignore
    Enable-ScheduledTask -TaskName 'ITPC-AVD-WarmUpUser'
} else {

    $localUserName = "warmup-user"

    $characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#' #$%^&*()-_=+[]{}|;:,.<>?'
    $localUserPWPlain = -join (1..32 | ForEach-Object { Get-Random -InputObject $characters.ToCharArray() }) +"!@1aA"

    $localUserPW =  ConvertTo-SecureString -String $localUserPWPlain -AsPlainText -Force # https://www.sharepointdiary.com/2020/04/powershell-generate-random-password.html


    # Store default configuration
    LogWriter("Getting local configuration to use saved credentials for the warmup user")
    $oldAuthenticationLevelOverride=$null
    if (Test-Path "HKCU:\Software\Microsoft\Terminal Server Client") {
        $oldAuthenticationLevelOverride = Get-ItemProperty -Path "HKCU:\Software\Microsoft\Terminal Server Client" -Name "AuthenticationLevelOverride" -ErrorAction SilentlyContinue
    } else {
        New-Item -Path "HKCU:\Software\Microsoft\Terminal Server Client" -Force -ErrorAction SilentlyContinue
    }
    if ($oldAuthenticationLevelOverride -eq $null) {$oldAuthenticationLevelOverride = 99} else {$oldAuthenticationLevelOverride = $oldAuthenticationLevelOverride.AuthenticationLevelOverride}


    try {
        LogWriter("Modifying configuration to login warmup user with stored credentials")
        # Set temporary configuration to allow user logins
        New-ItemProperty -Path "HKCU:\Software\Microsoft\Terminal Server Client" -Name "AuthenticationLevelOverride" -Value 0 -Force
        New-ItemProperty -Path "HKLM:\Software\Policies\Microsoft\Windows NT\Terminal Services" -Name "DisablePasswordSaving" -Value 1 -Force

        # Prepare temporary local user
        if ((Get-LocalUser -Name $localUserName -ErrorAction SilentlyContinue) -ne $null) {
            LogWriter("Removing existing local warmup user")
            Remove-LocalUser -Name $localUserName
        }
        LogWriter("Adding local user")
        $localUser = New-LocalUser -Name $localUserName -Password $localUserPW -AccountExpires (Get-Date).AddMinutes(10) -PasswordNeverExpires -UserMayNotChangePassword
        # Add user to remote desktop group
        LogWriter("Adding user to the remote desktop group")
        Add-LocalGroupMember -SID "S-1-5-32-555" -Member $localUserName -ErrorAction SilentlyContinue

        # Login user
        LogWriter("Adding credentials of the warmup user to the key store")
        cmdkey /generic:TERMSRV/localhost /user:"$($env:Computername)\$($localUserName)" /pass:$localUserPWPlain
        $startTime=Get-Date


	$i=0
	do {
            LogWriter("Login in user warmup user")
            if ($mstsc) {
		$mstsc.Kill()
		Start-Sleep -Seconds 6
	    }
            $mstsc = Start-Process -PassThru -FilePath "mstsc.exe" -ArgumentList "/v:localhost"
    
            # Wait for the user to login 
            LogWriter("Waiting a few seconds")
            Start-Sleep -Seconds 20
            $i++		
	} while ([Rds.Sessions]::GetSession($localUserName) -eq $null -and $i -le 10)

        # Remove credentials
        LogWriter("Removing credentials from the key store")
        cmdkey /delete:TERMSRV/localhost

        # Check, if user is connected
        $session=[Rds.Sessions]::GetSession($localUserName)

        if ($session -eq $null) {
            LogWriter("Warning: Warm-up user is not connected. So, the workaround is not working for some reasons")
        } else {
            # Let the user for a while in the session and then logoff
            LogWriter("Let the user logged in for a while")
            Start-Sleep -Seconds 300
            LogWriter("Logoff user")
            [Rds.Sessions]::LogoffSession($session.SessionId, $true)
            # Remove profile of the user
            try {
                LogWriter("Delete profile of the warm-up user")
                @(Get-CimInstance -Class Win32_UserProfile | Where-Object { $_.Loaded -eq $false -and ($_.LocalPath.split('\')[-1] -like $localUserName -or $_.LocalPath.split('\')[-1] -like "$($localUserName).*")}) | Remove-CimInstance

            } catch {
                LogWriter("Error: Cannot delete the profile of the warmup user: $_")
            }

        }
        LogWriter("Terminate mstsc.exe, if running")
        $mstsc.Kill()
    } catch {
        LogWriter("Error: $_")
    } finally {
        # Remove user
        LogWriter("Disable and remove user")
        Remove-LocalGroupMember -SID "S-1-5-32-555" -Member $localUserName
        Disable-LocalUser -Name  $localUserName -ErrorAction SilentlyContinue
        Remove-LocalUser -Name $localUserName -ErrorAction SilentlyContinue

        # Restore default configuration
        LogWriter("Restoring local configuration")
        if ($oldAuthenticationLevelOverride -eq 99) {
            Remove-ItemProperty "HKCU:\Software\Microsoft\Terminal Server Client" -Name "AuthenticationLevelOverride" -Force
        } else {
            New-ItemProperty -Path "HKCU:\Software\Microsoft\Terminal Server Client" -Name "AuthenticationLevelOverride" -Value $oldAuthenticationLevelOverride -Force
        }
    }
    LogWriter("Done")
}