File: SpaProxyLaunchManager.cs
Web Access
Project: src\src\Middleware\Spa\SpaProxy\src\Microsoft.AspNetCore.SpaProxy.csproj (Microsoft.AspNetCore.SpaProxy)
// 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.Net.Http;
using System.Net.Http.Headers;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Microsoft.AspNetCore.SpaProxy;
 
internal sealed class SpaProxyLaunchManager : IDisposable
{
    private readonly SpaDevelopmentServerOptions _options;
    private readonly ILogger<SpaProxyLaunchManager> _logger;
    private readonly object _lock = new object();
 
    private Process? _spaProcess;
    private bool _disposedValue;
    private Task? _launchTask;
 
    public SpaProxyLaunchManager(
        ILogger<SpaProxyLaunchManager> logger,
        IHostApplicationLifetime appLifetime,
        IOptions<SpaDevelopmentServerOptions> options)
    {
        _options = options.Value;
        _logger = logger;
        appLifetime.ApplicationStopping.Register(() => Dispose(true));
    }
 
    public void StartInBackground(CancellationToken cancellationToken)
    {
        // We are not waiting for the SPA proxy to launch, instead we are going to rely on a piece of
        // middleware to display an HTML document while the SPA proxy is not ready, refresh every three
        // seconds and redirect to the SPA proxy url once it is ready.
        // Being ready in this context means that we were able to receive a 200 from the proxy or that
        // we gave up waiting.
        // We do this to ensure Visual Studio can work correctly with IIS and when running without debugging.
        lock (_lock)
        {
            if (_launchTask == null)
            {
                if (_logger.IsEnabled(LogLevel.Information))
                {
                    _logger.LogInformation($"No SPA development server running at {_options.ServerUrl} found.");
                }
                _launchTask = UpdateStatus(StartSpaProcessAndProbeForLiveness(cancellationToken));
            }
        }
 
        async Task UpdateStatus(Task launchTask)
        {
            try
            {
                await launchTask;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "There was an error trying to launch the SPA proxy.");
            }
            finally
            {
                lock (_lock)
                {
                    _launchTask = null;
                }
            }
        }
    }
 
    public async Task<bool> IsSpaProxyRunning(CancellationToken cancellationToken)
    {
        var httpClient = CreateHttpClient();
 
        using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(10));
        try
        {
            var response = await httpClient.GetAsync(_options.ServerUrl, cancellationTokenSource.Token);
            var running = response.IsSuccessStatusCode;
            return running;
        }
        catch (Exception exception) when (exception is HttpRequestException ||
              exception is TaskCanceledException ||
              exception is OperationCanceledException)
        {
            _logger.LogDebug(exception, "Failed to connect to the SPA Development proxy.");
            return false;
        }
    }
 
    private static HttpClient CreateHttpClient()
    {
        var httpClient = new HttpClient(new HttpClientHandler()
        {
            // It's ok for us to do this here since this service is only plugged in during development.
            ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
        });
        // We don't care about the returned content type as long as the server is able to answer with 2XX
        httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*", 0.1));
        return httpClient;
    }
 
    private async Task<bool> ProbeSpaDevelopmentServerUrl(HttpClient httpClient, CancellationToken cancellationToken)
    {
        using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(10));
        try
        {
            var response = await httpClient.GetAsync(_options.ServerUrl, cancellationTokenSource.Token);
            var running = response.IsSuccessStatusCode;
            return running;
        }
        catch (Exception exception) when (exception is HttpRequestException ||
              exception is TaskCanceledException ||
              exception is OperationCanceledException)
        {
            _logger.LogDebug(exception, "Failed to connect to the SPA Development proxy.");
            return false;
        }
    }
 
    private async Task StartSpaProcessAndProbeForLiveness(CancellationToken cancellationToken)
    {
        LaunchDevelopmentProxy();
        var sw = Stopwatch.StartNew();
        var livenessProbeSucceeded = false;
        var maxTimeoutReached = false;
        var httpClient = CreateHttpClient();
        while (_spaProcess != null && !_spaProcess.HasExited && !maxTimeoutReached)
        {
            livenessProbeSucceeded = await ProbeSpaDevelopmentServerUrl(httpClient, cancellationToken);
            if (livenessProbeSucceeded)
            {
                break;
            }
 
            if (cancellationToken.IsCancellationRequested)
            {
                return;
            }
 
            maxTimeoutReached = sw.Elapsed >= _options.MaxTimeout;
            await Task.Delay(1000, cancellationToken);
        }
 
        if (_spaProcess == null || _spaProcess.HasExited)
        {
            if (_logger.IsEnabled(LogLevel.Error))
            {
                _logger.LogError($"Couldn't start the SPA development server with command '{_options.LaunchCommand}'.");
            }
        }
        else if (!livenessProbeSucceeded)
        {
            if (_logger.IsEnabled(LogLevel.Error))
            {
                _logger.LogError($"Unable to connect to the SPA development server at '{_options.ServerUrl}'.");
            }
        }
        else if (_logger.IsEnabled(LogLevel.Information))
        {
            _logger.LogInformation($"SPA development server running at '{_options.ServerUrl}'");
        }
    }
 
    private void LaunchDevelopmentProxy()
    {
        try
        {
            // Launch command is going to be something like `npm/yarn <<verb>> <<options>>`
            // We split it into two to separate the tool (command) from the verb and the rest of the arguments.
            var space = _options.LaunchCommand.IndexOf(' ');
            var command = _options.LaunchCommand[0..space];
            var arguments = _options.LaunchCommand[++space..];
            if (OperatingSystem.IsWindows() && !Path.HasExtension(command))
            {
                // On windows we transform npm/yarn to npm.cmd/yarn.cmd so that the command
                // can actually be found when we start the process. This is overridable if
                // necessary by explicitly setting up the extension on the command.
                command = $"{command}.cmd";
            }
 
            var info = new ProcessStartInfo(command, arguments)
            {
                // Linux and Mac OS don't have the concept of launching a terminal process in a new window.
                // On those cases the process will be launched in the same terminal window and will just print
                // some output during the start phase of the app.
                // This is not a problem since users don't need to interact with the proxy other than to stop it
                // and this is only an optimization to keep the current experience. We can always tell them to
                // run the proxy manually.
                CreateNoWindow = false,
                UseShellExecute = true,
                WindowStyle = ProcessWindowStyle.Normal,
                WorkingDirectory = Path.Combine(AppContext.BaseDirectory, _options.WorkingDirectory)
            };
            _spaProcess = Process.Start(info);
            if (_spaProcess != null && !_spaProcess.HasExited && !_options.KeepRunning)
            {
                if (OperatingSystem.IsWindows())
                {
                    LaunchStopScriptWindows(_spaProcess.Id);
                }
                else if (OperatingSystem.IsMacOS())
                {
                    LaunchStopScriptMacOS(_spaProcess.Id);
                }
            }
        }
        catch (Exception exception)
        {
            _logger.LogError(exception, $"Failed to launch the SPA development server '{_options.LaunchCommand}'.");
        }
    }
 
    private void LaunchStopScriptWindows(int spaProcessId)
    {
        var stopScript = $@"do{{
  try
  {{
    $processId = Get-Process -PID {Environment.ProcessId} -ErrorAction Stop;
  }}catch
  {{
    $processId = $null;
  }}
  Start-Sleep -Seconds 1;
}}while($processId -ne $null);
 
try
{{
  taskkill /T /F /PID {spaProcessId};
}}
catch
{{
}}";
        var stopScriptInfo = new ProcessStartInfo(
            "powershell.exe",
            string.Join(" ", "-NoProfile", "-C", stopScript))
        {
            CreateNoWindow = true,
            WorkingDirectory = Path.Combine(AppContext.BaseDirectory, _options.WorkingDirectory)
        };
 
        var stopProcess = Process.Start(stopScriptInfo);
        if (stopProcess == null || stopProcess.HasExited)
        {
            if (_logger.IsEnabled(LogLevel.Warning))
            {
                _logger.LogWarning($"The SPA process shutdown script '{stopProcess?.Id}' failed to start. The SPA proxy might" +
                    $" remain open if the dotnet process is terminated ungracefully. Use the operating system commands to kill" +
                    $" the process tree for {spaProcessId}");
            }
        }
        else
        {
            if (_logger.IsEnabled(LogLevel.Debug))
            {
                _logger.LogDebug($"Watch process '{stopProcess}' started.");
            }
        }
    }
 
    private void LaunchStopScriptMacOS(int spaProcessId)
    {
        var fileName = Guid.NewGuid().ToString("N") + ".sh";
        var scriptPath = Path.Combine(AppContext.BaseDirectory, fileName);
        var stopScript = @$"function list_child_processes () {{
    local ppid=$1;
    local current_children=$(pgrep -P $ppid);
    local local_child;
    if [ $? -eq 0 ];
    then
        for current_child in $current_children
        do
          local_child=$current_child;
          list_child_processes $local_child;
          echo $local_child;
        done;
    else
      return 0;
    fi;
}}
 
ps {Environment.ProcessId};
while [ $? -eq 0 ];
do
  sleep 1;
  ps {Environment.ProcessId} > /dev/null;
done;
 
for child in $(list_child_processes {spaProcessId});
do
  echo killing $child;
  kill -s KILL $child;
done;
rm {scriptPath};
";
        File.WriteAllText(scriptPath, stopScript.ReplaceLineEndings());
 
        var stopScriptInfo = new ProcessStartInfo("/bin/bash", scriptPath)
        {
            CreateNoWindow = true,
            WorkingDirectory = Path.Combine(AppContext.BaseDirectory, _options.WorkingDirectory)
        };
 
        var stopProcess = Process.Start(stopScriptInfo);
        if (stopProcess == null || stopProcess.HasExited)
        {
            _logger.LogWarning($"The SPA process shutdown script '{stopProcess?.Id}' failed to start. The SPA proxy might" +
                $" remain open if the dotnet process is terminated ungracefully. Use the operating system commands to kill" +
                $" the process tree for {spaProcessId}");
        }
    }
 
    public Task StopAsync()
    {
        Dispose(true);
        return Task.CompletedTask;
    }
 
    private void Dispose(bool disposing)
    {
        if (!_disposedValue)
        {
            if (disposing)
            {
                // Nothing to do here since ther are no managed resources
            }
 
            try
            {
                if (_spaProcess != null && !_spaProcess.HasExited)
                {
                    // Review: Whether or not to do this at all. Turns out that if we try to kill the
                    // npm.cmd/ps1 process that we start, even with this option we only stop this process
                    // and the service keeps running.
                    // Compared to performing Ctrl+C on the window or closing the window for the newly spawned
                    // process which seems to do the right thing.
                    // Process.CloseMainWindow seems to do the right thing in this situation and is doable since
                    // we now start a proxy every time.
                    // We can't guarantee that we stop/cleanup the proxy on every situation (for example if someone)
                    // kills this process in a "rude" way, but this gets 95% there.
                    // For cases where the proxy is left open and where there might not be a "visible" window the recomendation
                    // is to kill the process manually. (We will not fail, we will simply notify the proxy is "already" up.
                    if (!_spaProcess.CloseMainWindow())
                    {
                        _spaProcess.Kill(entireProcessTree: true);
                        _spaProcess = null;
                    }
                }
            }
            catch (Exception)
            {
                // Avoid throwing if we are running inside the finalizer.
                if (disposing)
                {
                    throw;
                }
            }
 
            _disposedValue = true;
        }
    }
 
    ~SpaProxyLaunchManager()
    {
        Dispose(disposing: false);
    }
 
    void IDisposable.Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }
}