File: Aspire\AspireServiceFactory.cs
Web Access
Project: ..\..\..\src\BuiltInTools\dotnet-watch\dotnet-watch.csproj (dotnet-watch)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Threading.Channels;
using Aspire.Tools.Service;
using Microsoft.Build.Graph;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.DotNet.Watch;
 
internal class AspireServiceFactory : IRuntimeProcessLauncherFactory
{
    internal sealed class SessionManager : IAspireServerEvents, IRuntimeProcessLauncher
    {
        private readonly struct Session(string dcpId, string sessionId, RunningProject runningProject, Task outputReader)
        {
            public string DcpId { get; } = dcpId;
            public string Id { get; } = sessionId;
            public RunningProject RunningProject { get; } = runningProject;
            public Task OutputReader { get; } = outputReader;
        }
 
        private static readonly UnboundedChannelOptions s_outputChannelOptions = new()
        {
            SingleReader = true,
            SingleWriter = true
        };
 
        private readonly ProjectLauncher _projectLauncher;
        private readonly AspireServerService _service;
        private readonly ProjectOptions _hostProjectOptions;
        private readonly ILogger _logger;
 
        /// <summary>
        /// Lock to access:
        /// <see cref="_sessions"/>
        /// <see cref="_sessionIdDispenser"/>
        /// </summary>
        private readonly object _guard = new();
 
        private readonly Dictionary<string, Session> _sessions = [];
        private int _sessionIdDispenser;
        private volatile bool _isDisposed;
 
        public SessionManager(ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions)
        {
            _projectLauncher = projectLauncher;
            _hostProjectOptions = hostProjectOptions;
            _logger = projectLauncher.LoggerFactory.CreateLogger(AspireLogComponentName);
 
            _service = new AspireServerService(
                this,
                displayName: ".NET Watch Aspire Server",
                m => _logger.LogDebug(m));
        }
 
        public async ValueTask DisposeAsync()
        {
#if DEBUG
            lock (_guard)
            {
                Debug.Assert(_sessions.Count == 0);
            }
#endif
            _isDisposed = true;
 
            await _service.DisposeAsync();
        }
 
        public async ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationToken)
        {
            ObjectDisposedException.ThrowIf(_isDisposed, this);
 
            ImmutableArray<Session> sessions;
            lock (_guard)
            {
                // caller guarantees the session is active
                sessions = [.. _sessions.Values];
                _sessions.Clear();
            }
 
            foreach (var session in sessions)
            {
                await TerminateSessionAsync(session, cancellationToken);
            }
        }
 
        public IEnumerable<(string name, string value)> GetEnvironmentVariables()
            => _service.GetServerConnectionEnvironment().Select(kvp => (kvp.Key, kvp.Value));
 
        /// <summary>
        /// Implements https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#create-session-request.
        /// </summary>
        async ValueTask<string> IAspireServerEvents.StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancellationToken)
        {
            ObjectDisposedException.ThrowIf(_isDisposed, this);
 
            var projectOptions = GetProjectOptions(projectLaunchInfo);
            var sessionId = Interlocked.Increment(ref _sessionIdDispenser).ToString(CultureInfo.InvariantCulture);
            await StartProjectAsync(dcpId, sessionId, projectOptions, isRestart: false, cancellationToken);
            return sessionId;
        }
 
        public async ValueTask<RunningProject> StartProjectAsync(string dcpId, string sessionId, ProjectOptions projectOptions, bool isRestart, CancellationToken cancellationToken)
        {
            ObjectDisposedException.ThrowIf(_isDisposed, this);
 
            _logger.LogDebug("Starting project: {Path}", projectOptions.ProjectPath);
 
            var processTerminationSource = new CancellationTokenSource();
            var outputChannel = Channel.CreateUnbounded<OutputLine>(s_outputChannelOptions);
 
            var runningProject = await _projectLauncher.TryLaunchProcessAsync(
                projectOptions,
                processTerminationSource,
                onOutput: line =>
                {
                    var writeResult = outputChannel.Writer.TryWrite(line);
                    Debug.Assert(writeResult);
                },
                restartOperation: cancellationToken =>
                    StartProjectAsync(dcpId, sessionId, projectOptions, isRestart: true, cancellationToken),
                cancellationToken);
 
            if (runningProject == null)
            {
                // detailed error already reported:
                throw new ApplicationException($"Failed to launch project '{projectOptions.ProjectPath}'.");
            }
 
            await _service.NotifySessionStartedAsync(dcpId, sessionId, runningProject.ProcessId, cancellationToken);
 
            // cancel reading output when the process terminates:
            var outputReader = StartChannelReader(processTerminationSource.Token);
 
            lock (_guard)
            {
                // When process is restarted we reuse the session id.
                // The session already exists, it needs to be updated with new info.
                Debug.Assert(_sessions.ContainsKey(sessionId) == isRestart);
 
                _sessions[sessionId] = new Session(dcpId, sessionId, runningProject, outputReader);
            }
 
            _logger.LogDebug("Session started: #{SessionId}", sessionId);
            return runningProject;
 
            async Task StartChannelReader(CancellationToken cancellationToken)
            {
                try
                {
                    await foreach (var line in outputChannel.Reader.ReadAllAsync(cancellationToken))
                    {
                        await _service.NotifyLogMessageAsync(dcpId, sessionId, isStdErr: line.IsError, data: line.Content, cancellationToken);
                    }
                }
                catch (Exception e)
                {
                    if (e is not OperationCanceledException)
                    {
                        _logger.LogError("Unexpected error reading output of session '{SessionId}': {Exception}", sessionId, e);
                    }
                }
            }
        }
 
        /// <summary>
        /// Implements https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#stop-session-request.
        /// </summary>
        async ValueTask<bool> IAspireServerEvents.StopSessionAsync(string dcpId, string sessionId, CancellationToken cancellationToken)
        {
            ObjectDisposedException.ThrowIf(_isDisposed, this);
 
            Session session;
            lock (_guard)
            {
                if (!_sessions.TryGetValue(sessionId, out session))
                {
                    return false;
                }
 
                _sessions.Remove(sessionId);
            }
 
            await TerminateSessionAsync(session, cancellationToken);
            return true;
        }
 
        private async ValueTask TerminateSessionAsync(Session session, CancellationToken cancellationToken)
        {
            _logger.LogDebug("Stop session #{SessionId}", session.Id);
 
            var exitCode = await _projectLauncher.TerminateProcessAsync(session.RunningProject, cancellationToken);
 
            // Wait until the started notification has been sent so that we don't send out of order notifications:
            await _service.NotifySessionEndedAsync(session.DcpId, session.Id, session.RunningProject.ProcessId, exitCode, cancellationToken);
 
            // process termination should cancel output reader task:
            await session.OutputReader;
        }
 
        private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo)
        {
            var hostLaunchProfile = _hostProjectOptions.NoLaunchProfile ? null : _hostProjectOptions.LaunchProfileName;
 
            return new()
            {
                IsRootProject = false,
                ProjectPath = projectLaunchInfo.ProjectPath,
                WorkingDirectory = Path.GetDirectoryName(projectLaunchInfo.ProjectPath) ?? throw new InvalidOperationException(),
                BuildArguments = _hostProjectOptions.BuildArguments,
                Command = "run",
                CommandArguments = GetRunCommandArguments(projectLaunchInfo, hostLaunchProfile),
                LaunchEnvironmentVariables = projectLaunchInfo.Environment?.Select(e => (e.Key, e.Value))?.ToArray() ?? [],
                LaunchProfileName = projectLaunchInfo.LaunchProfile,
                NoLaunchProfile = projectLaunchInfo.DisableLaunchProfile,
                TargetFramework = _hostProjectOptions.TargetFramework,
            };
        }
 
        // internal for testing
        internal static IReadOnlyList<string> GetRunCommandArguments(ProjectLaunchRequest projectLaunchInfo, string? hostLaunchProfile)
        {
            var arguments = new List<string>
            {
                "--project",
                projectLaunchInfo.ProjectPath,
            };
 
            // Implements https://github.com/dotnet/aspire/blob/main/docs/specs/IDE-execution.md#launch-profile-processing-project-launch-configuration
 
            if (projectLaunchInfo.DisableLaunchProfile)
            {
                arguments.Add("--no-launch-profile");
            }
            else if (!string.IsNullOrEmpty(projectLaunchInfo.LaunchProfile))
            {
                arguments.Add("--launch-profile");
                arguments.Add(projectLaunchInfo.LaunchProfile);
            }
            else if (hostLaunchProfile != null)
            {
                arguments.Add("--launch-profile");
                arguments.Add(hostLaunchProfile);
            }
 
            if (projectLaunchInfo.Arguments != null)
            {
                if (projectLaunchInfo.Arguments.Any())
                {
                    arguments.AddRange(projectLaunchInfo.Arguments);
                }
                else
                {
                    // indicate that no arguments should be used even if launch profile specifies some:
                    arguments.Add("--no-launch-profile-arguments");
                }
            }
 
            return arguments;
        }
    }
 
    public static readonly AspireServiceFactory Instance = new();
 
    public const string AspireLogComponentName = "Aspire";
    public const string AppHostProjectCapability = ProjectCapability.Aspire;
 
    public IRuntimeProcessLauncher? TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions)
        => projectNode.GetCapabilities().Contains(AppHostProjectCapability)
            ? new SessionManager(projectLauncher, hostProjectOptions)
            : null;
}