File: IISExpressDeployer.cs
Web Access
Project: src\src\Servers\IIS\IntegrationTesting.IIS\src\Microsoft.AspNetCore.Server.IntegrationTesting.IIS.csproj (Microsoft.AspNetCore.Server.IntegrationTesting.IIS)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.AspNetCore.Server.IntegrationTesting.Common;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.AspNetCore.Server.IntegrationTesting.IIS;
 
/// <summary>
/// Deployment helper for IISExpress.
/// </summary>
public partial class IISExpressDeployer : IISDeployerBase
{
    private const string IISExpressRunningMessage = "IIS Express is running.";
    private const string FailedToInitializeBindingsMessage = "Failed to initialize site bindings";
    private const string UnableToStartIISExpressMessage = "Unable to start iisexpress.";
    private const int MaximumAttempts = 5;
    private readonly TimeSpan ShutdownTimeSpan = Debugger.IsAttached ? TimeSpan.FromMinutes(60) : TimeSpan.FromMinutes(2);
    private static readonly Regex UrlDetectorRegex = new Regex(@"^\s*Successfully registered URL ""(?<url>[^""]+)"" for site.*$");
 
    private Process _hostProcess;
 
    public IISExpressDeployer(DeploymentParameters deploymentParameters, ILoggerFactory loggerFactory)
        : base(new IISDeploymentParameters(deploymentParameters), loggerFactory)
    {
    }
 
    public IISExpressDeployer(IISDeploymentParameters deploymentParameters, ILoggerFactory loggerFactory)
        : base(deploymentParameters, loggerFactory)
    {
    }
 
    protected override string ApplicationHostConfigPath => DeploymentParameters.ServerConfigLocation;
 
    public override async Task<DeploymentResult> DeployAsync()
    {
        using (Logger.BeginScope("Deployment"))
        {
            // Start timer
            StartTimer();
 
            Logger.LogInformation(Environment.OSVersion.ToString());
 
            // For an unpublished application the dllroot points pre-built dlls like projectdir/bin/debug/netcoreappX.Y/
            // and contentRoot points to the project directory so you get things like static assets.
            // For a published app both point to the publish directory.
            var dllRoot = CheckIfPublishIsRequired();
            string contentRoot;
            if (DeploymentParameters.PublishApplicationBeforeDeployment)
            {
                DotnetPublish();
                contentRoot = DeploymentParameters.PublishedApplicationRootPath;
            }
            else
            {
                // Core+Standalone always publishes. This must be Clr+Standalone or Core+Portable.
                // Update processPath and arguments for our current scenario
                contentRoot = DeploymentParameters.ApplicationPath;
 
                var executableExtension = DeploymentParameters.ApplicationType == ApplicationType.Portable ? ".dll" : ".exe";
                var entryPoint = Path.Combine(dllRoot, DeploymentParameters.ApplicationName + executableExtension);
 
                string executableName;
                var executableArgs = string.Empty;
 
                if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.CoreClr && DeploymentParameters.ApplicationType == ApplicationType.Portable)
                {
                    executableName = GetDotNetExeForArchitecture();
                    executableArgs = entryPoint;
                }
                else
                {
                    executableName = entryPoint;
                }
 
                Logger.LogInformation("Executing: {exe} {args}", executableName, executableArgs);
                DeploymentParameters.EnvironmentVariables["LAUNCHER_PATH"] = executableName;
                DeploymentParameters.EnvironmentVariables["LAUNCHER_ARGS"] = executableArgs;
 
                // CurrentDirectory will point to bin/{config}/{tfm}, but the config and static files aren't copied, point to the app base instead.
                Logger.LogInformation("ContentRoot: {path}", DeploymentParameters.ApplicationPath);
                DeploymentParameters.EnvironmentVariables["ASPNETCORE_CONTENTROOT"] = DeploymentParameters.ApplicationPath;
            }
 
            RunWebConfigActions(contentRoot);
 
            // Launch the host process.
            var (actualUri, hostExitToken) = await StartIISExpressAsync(contentRoot);
 
            Logger.LogInformation("Application ready at URL: {appUrl}", actualUri);
 
            // Right now this works only for urls like http://localhost:5001/. Does not work for http://localhost:5001/subpath.
 
            return new IISDeploymentResult(
                LoggerFactory,
                IISDeploymentParameters,
                applicationBaseUri: actualUri.ToString(),
                contentRoot: contentRoot,
                hostShutdownToken: hostExitToken,
                appPoolName: "IISExpressAppPool",
                hostProcess: _hostProcess);
        }
    }
 
    private string CheckIfPublishIsRequired()
    {
        string dllRoot = null;
        var targetFramework = DeploymentParameters.TargetFramework;
        if (!string.IsNullOrEmpty(DeploymentParameters.ApplicationPath))
        {
            // IISIntegration uses this layout
            dllRoot = Path.Combine(DeploymentParameters.ApplicationPath, "bin", DeploymentParameters.RuntimeArchitecture.ToString(),
                DeploymentParameters.Configuration, targetFramework);
 
            if (!Directory.Exists(dllRoot))
            {
                // Most repos use this layout
                dllRoot = Path.Combine(DeploymentParameters.ApplicationPath, "bin", DeploymentParameters.Configuration, targetFramework);
 
                if (!Directory.Exists(dllRoot))
                {
                    // The bits we need weren't pre-compiled, compile on publish
                    DeploymentParameters.PublishApplicationBeforeDeployment = true;
                }
                else if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.Clr
                         && DeploymentParameters.RuntimeArchitecture == RuntimeArchitecture.x86)
                {
                    // x64 is the default. Publish to rebuild for the right bitness
                    DeploymentParameters.PublishApplicationBeforeDeployment = true;
                }
            }
        }
 
        if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.CoreClr
                && DeploymentParameters.ApplicationType == ApplicationType.Standalone)
        {
            // Publish is always required to get the correct standalone files in the output directory
            DeploymentParameters.PublishApplicationBeforeDeployment = true;
        }
 
        return dllRoot;
    }
 
    private async Task<(Uri url, CancellationToken hostExitToken)> StartIISExpressAsync(string contentRoot)
    {
        using (Logger.BeginScope("StartIISExpress"))
        {
            var iisExpressPath = GetIISExpressPath();
 
            for (var attempt = 0; attempt < MaximumAttempts; attempt++)
            {
                var uri = TestUriHelper.BuildTestUri(ServerType.IISExpress, DeploymentParameters.ApplicationBaseUriHint);
                var port = uri.Port;
                if (port == 0)
                {
                    port = (uri.Scheme == "https") ? TestPortHelper.GetNextSSLPort() : TestPortHelper.GetNextPort();
                }
 
                Logger.LogInformation("Attempting to start IIS Express on port: {port}", port);
                PrepareConfig(contentRoot, port);
 
                var parameters = string.IsNullOrEmpty(DeploymentParameters.ServerConfigLocation) ?
                                string.Format(CultureInfo.InvariantCulture, "/port:{0} /path:\"{1}\" /trace:error /systray:false", uri.Port, contentRoot) :
                                string.Format(CultureInfo.InvariantCulture, "/site:{0} /config:{1} /trace:error /systray:false", DeploymentParameters.SiteName, DeploymentParameters.ServerConfigLocation);
 
                Logger.LogInformation("Executing command : {iisExpress} {parameters}", iisExpressPath, parameters);
 
                var startInfo = new ProcessStartInfo
                {
                    FileName = iisExpressPath,
                    Arguments = parameters,
                    UseShellExecute = false,
                    CreateNoWindow = true,
                    RedirectStandardError = true,
                    RedirectStandardOutput = true,
                    // VS sets current directory to C:\Program Files\IIS Express
                    WorkingDirectory = Path.GetDirectoryName(iisExpressPath)
                };
 
                AddEnvironmentVariablesToProcess(startInfo, DeploymentParameters.EnvironmentVariables);
 
                Uri url = null;
                var started = new TaskCompletionSource<bool>();
 
                var process = new Process() { StartInfo = startInfo };
                process.OutputDataReceived += (sender, dataArgs) =>
                {
                    if (string.Equals(dataArgs.Data, UnableToStartIISExpressMessage, StringComparison.Ordinal))
                    {
                        // We completely failed to start and we don't really know why
                        started.TrySetException(new InvalidOperationException("Failed to start IIS Express"));
                    }
                    else if (string.Equals(dataArgs.Data, FailedToInitializeBindingsMessage, StringComparison.Ordinal))
                    {
                        started.TrySetResult(false);
                    }
                    else if (string.Equals(dataArgs.Data, IISExpressRunningMessage, StringComparison.Ordinal))
                    {
                        started.TrySetResult(true);
                    }
                    else if (!string.IsNullOrEmpty(dataArgs.Data))
                    {
                        var m = UrlDetectorRegex.Match(dataArgs.Data);
                        if (m.Success)
                        {
                            url = new Uri(m.Groups["url"].Value);
                        }
                    }
                };
 
                process.EnableRaisingEvents = true;
                var hostExitTokenSource = new CancellationTokenSource();
                process.Exited += (sender, e) =>
                {
                    Logger.LogInformation("iisexpress Process {pid} shut down", process.Id);
 
                    // If TrySetResult was called above, this will just silently fail to set the new state, which is what we want
                    started.TrySetException(new Exception($"Command exited unexpectedly with exit code: {process.ExitCode}"));
 
                    TriggerHostShutdown(hostExitTokenSource);
                };
                process.StartAndCaptureOutAndErrToLogger("iisexpress", Logger);
                Logger.LogInformation("iisexpress Process {pid} started", process.Id);
 
                if (process.HasExited)
                {
                    Logger.LogError("Host process {processName} {pid} exited with code {exitCode} or failed to start.", startInfo.FileName, process.Id, process.ExitCode);
                    throw new Exception("Failed to start host");
                }
 
                // Wait for the app to start
                // The timeout here is large, because we don't know how long the test could need. We cover a lot
                // of error cases above, but I want to make sure we eventually give up and don't hang the build
                // just in case we missed one -anurse
                if (!await started.Task.TimeoutAfter(TimeSpan.FromMinutes(15)))
                {
                    Logger.LogInformation("iisexpress Process {pid} failed to bind to port {port}, trying again", process.Id, port);
 
                    // Wait for the process to exit and try again
                    process.WaitForExit(30 * 1000);
                    await Task.Delay(1000); // Wait a second to make sure the socket is completely cleaned up
                }
                else
                {
                    _hostProcess = process;
 
                    // Ensure iisexpress.exe is killed if test process termination is non-graceful.
                    // Prevents locked files when stop debugging unit test.
                    ProcessTracker.Add(_hostProcess);
 
                    // cache the process start time for verifying log file name.
                    var _ = _hostProcess.StartTime;
 
                    Logger.LogInformation("Started iisexpress successfully. Process Id : {processId}, Port: {port}", _hostProcess.Id, port);
                    return (url: url, hostExitToken: hostExitTokenSource.Token);
                }
            }
 
            var message = $"Failed to initialize IIS Express after {MaximumAttempts} attempts to select a port";
            Logger.LogError(message);
            throw new TimeoutException(message);
        }
    }
 
    private void PrepareConfig(string contentRoot, int port)
    {
        var serverConfig = DeploymentParameters.ServerConfigTemplateContent;
        // Config is required. If not present then fall back to one we carry with us.
        if (string.IsNullOrEmpty(serverConfig))
        {
            using (var stream = GetType().Assembly.GetManifestResourceStream("Microsoft.AspNetCore.Server.IntegrationTesting.IIS.Http.config"))
            using (var reader = new StreamReader(stream))
            {
                serverConfig = reader.ReadToEnd();
            }
        }
 
        XDocument config = XDocument.Parse(serverConfig);
        // Pass on the applicationhost.config to iis express. With this don't need to pass in the /path /port switches as they are in the applicationHost.config
        // We take a copy of the original specified applicationHost.Config to prevent modifying the one in the repo.
 
        config.Root
            .RequiredElement("location")
            .RequiredElement("system.webServer")
            .RequiredElement("modules")
            .GetOrAdd("add", "name", AspNetCoreModuleV2ModuleName);
 
        ConfigureModuleAndBinding(config.Root, contentRoot, port);
 
        var webConfigPath = Path.Combine(contentRoot, "web.config");
        if (!DeploymentParameters.PublishApplicationBeforeDeployment && !File.Exists(webConfigPath))
        {
            // The elements normally in the web.config are in the applicationhost.config for unpublished apps.
            AddAspNetCoreElement(config.Root);
        }
 
        RunServerConfigActions(config.Root, contentRoot);
        serverConfig = config.ToString();
 
        DeploymentParameters.ServerConfigLocation = Path.GetTempFileName();
        Logger.LogDebug("Saving Config to {configPath}", DeploymentParameters.ServerConfigLocation);
 
        File.WriteAllText(DeploymentParameters.ServerConfigLocation, serverConfig);
    }
 
    private void AddAspNetCoreElement(XElement config)
    {
        var aspNetCore = config
            .RequiredElement("system.webServer")
            .GetOrAdd("aspNetCore");
 
        aspNetCore.SetAttributeValue("hostingModel", DeploymentParameters.HostingModel.ToString());
        aspNetCore.SetAttributeValue("arguments", "%LAUNCHER_ARGS%");
        aspNetCore.SetAttributeValue("processPath", "%LAUNCHER_PATH%");
 
        var handlers = config
            .RequiredElement("location")
            .RequiredElement("system.webServer")
            .RequiredElement("handlers");
 
        var aspNetCoreHandler = handlers
            .GetOrAdd("add", "name", "aspNetCore");
 
        aspNetCoreHandler.SetAttributeValue("path", "*");
        aspNetCoreHandler.SetAttributeValue("verb", "*");
        aspNetCoreHandler.SetAttributeValue("modules", AspNetCoreModuleV2ModuleName);
        aspNetCoreHandler.SetAttributeValue("resourceType", "Unspecified");
        // Make aspNetCore handler first
        aspNetCoreHandler.Remove();
        handlers.AddFirst(aspNetCoreHandler);
    }
 
    protected override IEnumerable<Action<XElement, string>> GetWebConfigActions()
    {
        if (IISDeploymentParameters.PublishApplicationBeforeDeployment)
        {
            // For published apps, prefer the content in the web.config, but update it.
            yield return WebConfigHelpers.AddOrModifyAspNetCoreSection(
                key: "hostingModel",
                value: DeploymentParameters.HostingModel.ToString());
 
            yield return WebConfigHelpers.AddOrModifyHandlerSection(
                key: "modules",
                value: AspNetCoreModuleV2ModuleName);
 
            // We assume the x64 dotnet.exe is on the path so we need to provide an absolute path for x86 scenarios.
            // Only do it for scenarios that rely on dotnet.exe (Core, portable, etc.).
            if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.CoreClr
                && DeploymentParameters.ApplicationType == ApplicationType.Portable
                && DotNetCommands.IsRunningX86OnX64(DeploymentParameters.RuntimeArchitecture))
            {
                var executableName = DotNetCommands.GetDotNetExecutable(DeploymentParameters.RuntimeArchitecture);
                if (!File.Exists(executableName))
                {
                    throw new Exception($"Unable to find '{executableName}'.'");
                }
                yield return WebConfigHelpers.AddOrModifyAspNetCoreSection("processPath", executableName);
            }
        }
 
        foreach (var action in base.GetWebConfigActions())
        {
            yield return action;
        }
    }
 
    private string GetIISExpressPath()
    {
        var programFiles = "Program Files";
        if (DotNetCommands.IsRunningX86OnX64(DeploymentParameters.RuntimeArchitecture))
        {
            programFiles = "Program Files (x86)";
        }
 
        // Get path to program files
        var iisExpressPath = Path.Combine(Environment.GetEnvironmentVariable("SystemDrive") + "\\", programFiles, "IIS Express", "iisexpress.exe");
 
        if (!File.Exists(iisExpressPath))
        {
            throw new Exception("Unable to find IISExpress on the machine: " + iisExpressPath);
        }
 
        return iisExpressPath;
    }
 
    public override void Dispose()
    {
        Dispose(gracefulShutdown: false);
    }
 
    public override void Dispose(bool gracefulShutdown)
    {
        using (Logger.BeginScope("Dispose"))
        {
            if (gracefulShutdown)
            {
                GracefullyShutdownProcess(_hostProcess);
            }
            else
            {
                ShutDownIfAnyHostProcess(_hostProcess);
            }
 
            if (!string.IsNullOrEmpty(DeploymentParameters.ServerConfigLocation)
                && File.Exists(DeploymentParameters.ServerConfigLocation))
            {
                // Delete the temp applicationHostConfig that we created.
                Logger.LogDebug("Deleting applicationHost.config file from {configLocation}", DeploymentParameters.ServerConfigLocation);
                try
                {
                    File.Delete(DeploymentParameters.ServerConfigLocation);
                }
                catch (Exception exception)
                {
                    // Ignore delete failures - just write a log.
                    Logger.LogWarning("Failed to delete '{config}'. Exception : {exception}", DeploymentParameters.ServerConfigLocation, exception.Message);
                }
            }
 
            if (DeploymentParameters.PublishApplicationBeforeDeployment)
            {
                CleanPublishedOutput();
            }
 
            InvokeUserApplicationCleanup();
 
            StopTimer();
        }
 
        // If by this point, the host process is still running (somehow), throw an error.
        // A test failure is better than a silent hang and unknown failure later on
        if (_hostProcess != null && !_hostProcess.HasExited)
        {
            throw new Exception($"iisexpress Process {_hostProcess.Id} failed to shutdown");
        }
    }
 
    private sealed partial class WindowsNativeMethods
    {
        internal delegate bool EnumWindowProc(IntPtr hwnd, IntPtr lParam);
        [LibraryImport("user32.dll")]
        internal static partial uint GetWindowThreadProcessId(IntPtr hwnd, out uint lpdwProcessId);
        [LibraryImport("user32.dll", EntryPoint = "PostMessageW")]
        [return: MarshalAs(UnmanagedType.Bool)]
        internal static partial bool PostMessage([MarshalUsing(typeof(HandleRefMarshaller))] HandleRef hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
        [LibraryImport("user32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        internal static partial bool EnumWindows(EnumWindowProc callback, IntPtr lParam);
        [LibraryImport("user32.dll", EntryPoint = "GetClassNameW", SetLastError = true)]
        internal static partial int GetClassName(IntPtr hWnd, [Out, MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.U2)] char[] lpClassName, int nMaxCount);
 
        [CustomMarshaller(typeof(HandleRef), MarshalMode.ManagedToUnmanagedIn, typeof(ManagedToUnmanagedIn))]
        internal static class HandleRefMarshaller
        {
            internal struct ManagedToUnmanagedIn
            {
                private HandleRef _handle;
 
                public void FromManaged(HandleRef handle)
                {
                    _handle = handle;
                }
 
                public IntPtr ToUnmanaged() => _handle.Handle;
 
                public void OnInvoked() => GC.KeepAlive(_handle.Wrapper);
 
                [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "This method is part of the marshaller shape and is required to be an instance method.")]
                public void Free() {}
            }
        }
    }
 
    private void SendStopMessageToProcess(int pid)
    {
        var found = false;
        var extraLogging = false;
        var retryCount = 5;
 
        while (!found && retryCount > 0)
        {
            Logger.LogInformation($"Sending shutdown request to {pid}");
 
            WindowsNativeMethods.EnumWindows((ptr, param) =>
            {
                WindowsNativeMethods.GetWindowThreadProcessId(ptr, out var windowProcessId);
                if (extraLogging)
                {
                    Logger.LogDebug($"EnumWindow returned {ptr} belonging to {windowProcessId}");
                }
 
                if (pid == windowProcessId)
                {
                    // 256 is the max length
                    char[] buffer = new char[256];
                    var length = WindowsNativeMethods.GetClassName(ptr, buffer, buffer.Length);
 
                    if (length == 0)
                    {
                        throw new InvalidOperationException($"Unable to get window class name: {Marshal.GetLastWin32Error()}");
                    }
 
                    var className = new string(buffer, 0, length);
                    if (!string.Equals(className, "IISEXPRESS", StringComparison.OrdinalIgnoreCase))
                    {
                        Logger.LogDebug($"Skipping window {ptr} with class name {className}");
                        // skip windows without IISEXPRESS class
                        return true;
                    }
 
                    var hWnd = new HandleRef(null, ptr);
                    if (!WindowsNativeMethods.PostMessage(hWnd, 0x12, IntPtr.Zero, IntPtr.Zero))
                    {
                        throw new InvalidOperationException($"Unable to PostMessage to process {pid}. LastError: {Marshal.GetLastWin32Error()}");
                    }
 
                    found = true;
                    return false;
                }
 
                return true;
            }, IntPtr.Zero);
 
            if (!found)
            {
                Thread.Sleep(100);
            }
 
            // Add extra logging if first try was unsuccessful
            extraLogging = true;
            retryCount--;
        }
 
        if (!found)
        {
            throw new InvalidOperationException($"Unable to find main window for process {pid}");
        }
    }
 
    private void GracefullyShutdownProcess(Process hostProcess)
    {
        if (hostProcess != null && !hostProcess.HasExited)
        {
            // Calling hostProcess.StandardInput.WriteLine("q") with StandardInput redirected
            // for the process does not work when stopping IISExpress
            // Also, hostProcess.CloseMainWindow() doesn't work either.
            // Instead we have to send WM_QUIT to the iisexpress process via pInvokes.
            // See: https://stackoverflow.com/questions/4772092/starting-and-stopping-iis-express-programmatically
 
            SendStopMessageToProcess(hostProcess.Id);
            if (!hostProcess.WaitForExit((int)ShutdownTimeSpan.TotalMilliseconds))
            {
                throw new InvalidOperationException($"iisexpress Process {hostProcess.Id} failed to gracefully shutdown.");
            }
            if (hostProcess.ExitCode != 0)
            {
                Logger.LogWarning($"IISExpress exit code is non-zero after graceful shutdown. Exit code: {hostProcess.ExitCode}");
                throw new InvalidOperationException($"IISExpress exit code is non-zero after graceful shutdown. Exit code: {hostProcess.ExitCode}.");
            }
        }
        else
        {
            throw new InvalidOperationException($"iisexpress Process {hostProcess?.Id} crashed before shutdown was triggered.");
        }
    }
}