File: HotReload\HotReloadDotNetWatcher.cs
Web Access
Project: src\src\sdk\src\Dotnet.Watch\Watch\Microsoft.DotNet.HotReload.Watch.csproj (Microsoft.DotNet.HotReload.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.Text.Encodings.Web;
using Microsoft.Build.Execution;
using Microsoft.CodeAnalysis;
using Microsoft.DotNet.HotReload;
using Microsoft.DotNet.ProjectTools;
using Microsoft.Extensions.Logging;

namespace Microsoft.DotNet.Watch;

internal sealed class HotReloadDotNetWatcher
{
    public const string ClientLogComponentName = $"{nameof(HotReloadDotNetWatcher)}:Client";
    public const string AgentLogComponentName = $"{nameof(HotReloadDotNetWatcher)}:Agent";

    private readonly IConsole _console;
    private readonly IRuntimeProcessLauncherFactory? _runtimeProcessLauncherFactory;
    private readonly RestartPrompt? _rudeEditRestartPrompt;
    private readonly BuildParametersSelectionPrompt? _selectionPrompt;

    private readonly DotNetWatchContext _context;
    private readonly ProjectGraphFactory _designTimeBuildGraphFactory;

    internal Task? Test_FileChangesCompletedTask { get; set; }

    public HotReloadDotNetWatcher(
        DotNetWatchContext context,
        IConsole console,
        IRuntimeProcessLauncherFactory? runtimeProcessLauncherFactory,
        BuildParametersSelectionPrompt? selectionPrompt)
    {
        _context = context;
        _console = console;
        _runtimeProcessLauncherFactory = runtimeProcessLauncherFactory;
        _selectionPrompt = selectionPrompt;
        if (!context.Options.NonInteractive)
        {
            var consoleInput = new ConsoleInputReader(_console, context.Options.LogLevel, context.EnvironmentOptions.SuppressEmojis);

            var noPrompt = context.EnvironmentOptions.RestartOnRudeEdit;
            if (noPrompt)
            {
                context.Logger.LogDebug("DOTNET_WATCH_RESTART_ON_RUDE_EDIT = 'true'. Will restart without prompt.");
            }

            _rudeEditRestartPrompt = new RestartPrompt(context.Logger, consoleInput, noPrompt ? true : null);
        }

        _designTimeBuildGraphFactory = new ProjectGraphFactory(
            context.RootProjects,
            buildProperties: EvaluationResult.GetGlobalBuildProperties(
                context.BuildArguments,
                context.EnvironmentOptions),
            context.BuildLogger,
            context.Options,
            context.EnvironmentOptions);
    }

    public async Task WatchAsync(CancellationToken shutdownCancellationToken)
    {
        CancellationTokenSource? forceRestartCancellationSource = null;

        _context.Logger.Log(MessageDescriptor.HotReloadEnabled);
        _context.Logger.Log(MessageDescriptor.PressCtrlRToRestart);

        _console.KeyPressed += (key) =>
        {
            if (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.R && forceRestartCancellationSource is { } source)
            {
                // provide immediate feedback to the user:
                _context.Logger.Log(source.IsCancellationRequested ? MessageDescriptor.RestartInProgress : MessageDescriptor.RestartRequested);
                source.Cancel();
            }
        };

        using var fileWatcher = new FileWatcher(_context.Logger, _context.EnvironmentOptions);

        for (var iteration = 0; !shutdownCancellationToken.IsCancellationRequested; iteration++)
        {
            Interlocked.Exchange(ref forceRestartCancellationSource, new CancellationTokenSource())?.Dispose();

            // This source will signal when the user cancels (either Ctrl+R or Ctrl+C):
            using var iterationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token);
            var iterationCancellationToken = iterationCancellationSource.Token;

            var suppressWaitForFileChange = false;
            EvaluationResult? evaluationResult = null;
            RunningProject? mainRunningProject = null;
            IRuntimeProcessLauncher? runtimeProcessLauncher = null;
            CompilationHandler? compilationHandler = null;
            Action<ChangedPath>? fileChangedCallback = null;
            LoadedProjectGraph? projectGraph = null;
            BuildProjectsResult? rootProjectsBuildResult = null;

            try
            {
                rootProjectsBuildResult = await BuildProjectsAsync(
                    _context.RootProjects,
                    fileWatcher,
                    _context.MainProjectOptions,
                    frameworkSelector: _selectionPrompt != null ? _selectionPrompt.SelectTargetFrameworkAsync : null,
                    deviceSelector: _selectionPrompt != null ? _selectionPrompt.SelectDeviceAsync : null,
                    iterationCancellationToken);

                // Try load project graph and perform design-time build even if the build failed.
                // This allows us to watch the project and source files for changes that will trigger restart.

                projectGraph = rootProjectsBuildResult.ProjectGraph ?? TryLoadProjectGraph(rootProjectsBuildResult.MainProjectTargetFramework, iterationCancellationToken);
                if (projectGraph == null)
                {
                    continue;
                }

                fileWatcher.WatchFiles(projectGraph.BuildFiles);

                // Avoid restore since the build above already restored all root projects.
                evaluationResult = await TryEvaluateProjectGraphAsync(projectGraph, rootProjectsBuildResult.MainProjectTargetFramework, restore: false, iterationCancellationToken);
                if (evaluationResult == null)
                {
                    continue;
                }

                evaluationResult.ItemExclusions.Report(_context.Logger);
                evaluationResult.WatchFileItems(fileWatcher);

                if (!rootProjectsBuildResult.Success)
                {
                    continue;
                }

                compilationHandler = new CompilationHandler(_context);

                // The session must start after the project is built and design time build completes,
                // so that the EnC service can read document checksums from the PDB and the solution
                // can be initialized from the current state of the project instances in the graph.
                //
                // Starting the session hydrates the contents of solution documents from disk.
                // Session must be started before we start accepting file changes to avoid race condition
                // when the EnC session hydrates solution documents with their file content after the changes have already been observed.
                await compilationHandler.StartSessionAsync(evaluationResult.ProjectGraph.Graph, iterationCancellationToken);

                var projectLauncher = new ProjectLauncher(_context, projectGraph, compilationHandler, iteration);

                var runtimeProcessLauncherFactory = _runtimeProcessLauncherFactory;

                var mainProjectOptions = _context.MainProjectOptions;
                if (mainProjectOptions != null)
                {
                    mainProjectOptions = mainProjectOptions with
                    {
                        TargetFramework = rootProjectsBuildResult.MainProjectTargetFramework,
                        Device = rootProjectsBuildResult.SelectedDevice,
                        DeviceRuntimeIdentifier = rootProjectsBuildResult.SelectedDeviceRuntimeIdentifier,
                    };

                    if (projectGraph.Graph.GraphRoots.Single()?.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability) == true)
                    {
                        runtimeProcessLauncherFactory ??= new AspireServiceFactory(mainProjectOptions);
                        _context.Logger.LogDebug("Using Aspire process launcher.");
                    }
                }

                if (runtimeProcessLauncherFactory != null)
                {
                    runtimeProcessLauncher = runtimeProcessLauncherFactory.Create(projectLauncher);
                    _context.Logger.Log(MessageDescriptor.RuntimeProcessLauncherCreatedNotification);
                }

                if (mainProjectOptions != null)
                {
                    if (runtimeProcessLauncher != null)
                    {
                        mainProjectOptions = mainProjectOptions with
                        {
                            LaunchEnvironmentVariables = [.. mainProjectOptions.LaunchEnvironmentVariables, .. runtimeProcessLauncher.GetEnvironmentVariables()]
                        };
                    }

                    mainRunningProject = await projectLauncher.TryLaunchProcessAsync(
                        mainProjectOptions,
                        onOutput: null,
                        onExit: (_, _) =>
                        {
                            iterationCancellationSource.Cancel();
                            return ValueTask.CompletedTask;
                        },
                        restartOperation: new RestartOperation(_ => default), // the process will automatically restart
                        iterationCancellationToken);

                    if (mainRunningProject == null)
                    {
                        // error has been reported:
                        return;
                    }

                    // Cancel iteration as soon as the main process exits, so that we don't spent time loading solution, etc. when the process is already dead.
                    mainRunningProject.Process.ExitedCancellationToken.Register(iterationCancellationSource.Cancel);
                }

                if (shutdownCancellationToken.IsCancellationRequested)
                {
                    // Ctrl+C:
                    return;
                }

                var changedFilesAccumulator = ImmutableList<ChangedPath>.Empty;

                void FileChangedCallback(ChangedPath change)
                {
                    if (AcceptChange(change, evaluationResult))
                    {
                        _context.Logger.LogDebug("File change: {Kind} '{Path}'.", change.Kind, change.Path);
                        ImmutableInterlocked.Update(ref changedFilesAccumulator, changedPaths => changedPaths.Add(change));
                    }
                }

                fileChangedCallback = FileChangedCallback;
                fileWatcher.OnFileChange += fileChangedCallback;
                _context.Logger.Log(MessageDescriptor.WaitingForChanges);

                if (Test_FileChangesCompletedTask != null)
                {
                    await Test_FileChangesCompletedTask;
                }

                // Hot Reload loop
                while (!iterationCancellationToken.IsCancellationRequested)
                {
                    ImmutableArray<ChangedFile> changedFiles;
                    do
                    {
                        // Use timeout to batch file changes. If the process doesn't exit within the given timespan we'll check
                        // for accumulated file changes. If there are any we attempt Hot Reload. Otherwise we come back here to wait again.
                        await Task.Delay(50, iterationCancellationToken);

                        // If the changes include addition/deletion wait a little bit more for possible matching deletion/addition.
                        // This eliminates reevaluations caused by teared add + delete of a temp file or a move of a file.
                        if (changedFilesAccumulator.Any(change => change.Kind is ChangeKind.Add or ChangeKind.Delete))
                        {
                            await Task.Delay(150, iterationCancellationToken);
                        }

                        changedFiles = await CaptureChangedFilesSnapshot(rebuiltProjects: []);
                    }
                    while (changedFiles is []);

                    var updates = new HotReloadProjectUpdatesBuilder();
                    var stopwatch = Stopwatch.StartNew();

                    await compilationHandler.GetStaticAssetUpdatesAsync(updates, changedFiles, evaluationResult, stopwatch, iterationCancellationToken);

                    await compilationHandler.GetManagedCodeUpdatesAsync(
                        updates,
                        restartPrompt: async (projectNames, cancellationToken) =>
                        {
                            // stop before waiting for user input:
                            stopwatch.Stop();
                            var result = await RestartPrompt(projectNames, runtimeProcessLauncher, cancellationToken);
                            stopwatch.Start();
                            return result;
                        },
                        autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true,
                        iterationCancellationToken);

                    // Terminate root process if it had rude edits or is non-reloadable.
                    if (updates.ProjectsToRestart.Any(static project => project.Options.IsMainProject))
                    {
                        Debug.Assert(mainRunningProject != null);
                        mainRunningProject.InitiateRestart();
                        break;
                    }

                    if (updates.ProjectsToRebuild is not [])
                    {
                        while (true)
                        {
                            iterationCancellationToken.ThrowIfCancellationRequested();

                            var result = await BuildProjectsAsync(
                                [.. updates.ProjectsToRebuild.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)],
                                fileWatcher,
                                mainProjectOptions,
                                frameworkSelector: null,
                                deviceSelector: null,
                                iterationCancellationToken);

                            if (result.Success)
                            {
                                break;
                            }

                            _ = await fileWatcher.WaitForFileChangeAsync(
                                change => AcceptChange(change, evaluationResult),
                                startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError),
                                shutdownCancellationToken);
                        }

                        // Changes made since last snapshot of the accumulator shouldn't be included in next Hot Reload update.
                        // Apply them to the workspace.
                        _ = await CaptureChangedFilesSnapshot(updates.ProjectsToRebuild);

                        _context.Logger.Log(MessageDescriptor.ProjectsRebuilt, updates.ProjectsToRebuild.Count);
                    }

                    // Deploy dependencies after rebuilding and before restarting.
                    if (updates.ProjectsToRedeploy is not [])
                    {
                        await DeployProjectDependenciesAsync(evaluationResult, updates.ProjectsToRedeploy, iterationCancellationToken);
                        _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, updates.ProjectsToRedeploy.Count);
                    }

                    // Apply updates only after dependencies have been deployed,
                    // so that updated code doesn't attempt to access the dependency before it has been deployed.
                    await compilationHandler.ApplyManagedCodeAndStaticAssetUpdatesAndRelaunchAsync(updates.ManagedCodeUpdates, updates.StaticAssetsToUpdate, changedFiles, evaluationResult.ProjectGraph, stopwatch, iterationCancellationToken);
                    if (updates.ProjectsToRestart is not [])
                    {
                        await compilationHandler.RestartPeripheralProjectsAsync(updates.ProjectsToRestart, shutdownCancellationToken);
                    }

                    async Task<ImmutableArray<ChangedFile>> CaptureChangedFilesSnapshot(IReadOnlyList<string> rebuiltProjects)
                    {
                        var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []);
                        if (changedPaths is [])
                        {
                            return [];
                        }

                        // Note:
                        // It is possible that we could have received multiple changes for a file that should cancel each other (such as Delete + Add),
                        // but they end up split into two snapshots and we will interpret them as two separate Delete and Add changes that trigger
                        // two sets of Hot Reload updates. Hence the normalization is best effort as we can't predict future.

                        var changedFiles = NormalizePathChanges(changedPaths)
                            .Select(changedPath =>
                            {
                                // On macOS may report Update followed by Add when a new file is created or just updated.
                                // We normalize Update + Add to just Add and Update + Add + Delete to Update above.
                                // To distinguish between an addition and an update we check if the file exists.

                                if (evaluationResult.Files.TryGetValue(changedPath.Path, out var existingFileItem))
                                {
                                    var changeKind = changedPath.Kind == ChangeKind.Add ? ChangeKind.Update : changedPath.Kind;

                                    return new ChangedFile(existingFileItem, changeKind);
                                }

                                // Do not assume the change is an addition, even if the file doesn't exist in the evaluation result.
                                // The file could have been deleted and Add + Delete sequence could have been normalized to Update.
                                return new ChangedFile(
                                    new FileItem() { FilePath = changedPath.Path, ContainingProjectPaths = [] },
                                    changedPath.Kind);
                            })
                            .ToList();

                        ReportFileChanges(changedFiles);

                        AnalyzeFileChanges(changedFiles, evaluationResult, out var evaluationRequired);

                        if (evaluationRequired)
                        {
                            // TODO: consider reloading/reevaluating only affected projects instead of the whole graph.

                            var targetFramework = mainProjectOptions?.TargetFramework;

                            while (true)
                            {
                                iterationCancellationToken.ThrowIfCancellationRequested();

                                projectGraph = TryLoadProjectGraph(targetFramework, iterationCancellationToken);
                                if (projectGraph != null)
                                {
                                    fileWatcher.WatchFiles(projectGraph.BuildFiles);

                                    evaluationResult = await TryEvaluateProjectGraphAsync(projectGraph, targetFramework, restore: true, iterationCancellationToken);
                                    if (evaluationResult != null)
                                    {
                                        break;
                                    }
                                }

                                _ = await fileWatcher.WaitForFileChangeAsync(
                                    acceptChange: AcceptChange,
                                    startedWatching: () => _context.Logger.Log(MessageDescriptor.FixBuildError),
                                    shutdownCancellationToken);
                            }

                            // additional files/directories may have been added:
                            evaluationResult.WatchFileItems(fileWatcher);

                            await compilationHandler.UpdateProjectGraphAsync(evaluationResult.ProjectGraph.Graph, iterationCancellationToken);

                            if (shutdownCancellationToken.IsCancellationRequested)
                            {
                                // Ctrl+C:
                                return [];
                            }

                            // Update files in the change set with new evaluation info.
                            for (var i = 0; i < changedFiles.Count; i++)
                            {
                                var file = changedFiles[i];
                                if (evaluationResult.Files.TryGetValue(file.Item.FilePath, out var evaluatedFile))
                                {
                                    changedFiles[i] = file with { Item = evaluatedFile };
                                }
                            }

                            _context.Logger.Log(MessageDescriptor.ReEvaluationCompleted);
                        }

                        if (rebuiltProjects is not [])
                        {
                            // Filter changed files down to those contained in projects being rebuilt.
                            // File changes that affect projects that are not being rebuilt will stay in the accumulator
                            // and be included in the next Hot Reload change set.
                            var rebuiltProjectPaths = rebuiltProjects.ToHashSet();

                            var newAccumulator = ImmutableList<ChangedPath>.Empty;
                            var newChangedFiles = new List<ChangedFile>();

                            foreach (var file in changedFiles)
                            {
                                if (file.Item.ContainingProjectPaths.All(rebuiltProjectPaths.Contains))
                                {
                                    newChangedFiles.Add(file);
                                }
                                else
                                {
                                    newAccumulator = newAccumulator.Add(new ChangedPath(file.Item.FilePath, file.Kind));
                                }
                            }

                            changedFiles = newChangedFiles;

                            ImmutableInterlocked.Update(ref changedFilesAccumulator, accumulator => accumulator.AddRange(newAccumulator));
                        }

                        if (!evaluationRequired)
                        {
                            // Update the workspace to reflect changes in the file content:.
                            // If the project was re-evaluated the Roslyn solution is already up to date.
                            await compilationHandler.UpdateFileContentAsync(changedFiles, iterationCancellationToken);
                        }

                        return [.. changedFiles];
                    }
                }
            }
            catch (OperationCanceledException) when (!shutdownCancellationToken.IsCancellationRequested)
            {
                // start next iteration unless shutdown is requested
            }
            catch (Exception) when (!(suppressWaitForFileChange = true))
            {
                // unreachable
            }
            finally
            {
                // stop watching file changes:
                if (fileChangedCallback != null)
                {
                    fileWatcher.OnFileChange -= fileChangedCallback;
                }

                if (runtimeProcessLauncher != null)
                {
                    // Dispose the launcher so that it won't start any new peripheral processes.
                    // Do this before terminating all processes, so that we don't leave any processes orphaned.
                    await runtimeProcessLauncher.DisposeAsync();
                }

                if (compilationHandler != null)
                {
                    // Non-cancellable - can only be aborted by forced Ctrl+C, which immediately kills the dotnet-watch process.
                    await compilationHandler.TerminatePeripheralProcessesAndDispose(CancellationToken.None);
                }

                if (mainRunningProject != null)
                {
                    await mainRunningProject.Process.TerminateAsync();
                }

                // Wait for file change
                // - if the process hasn't launched (e.g. build failed)
                // - if the process launched, has been terminated and is not being auto-restarted (rude edit),
                // unless Ctrl+R or Ctrl+C were pressed.
                if (shutdownCancellationToken.IsCancellationRequested)
                {
                    // no op
                }
                else if (forceRestartCancellationSource.IsCancellationRequested)
                {
                    _context.Logger.Log(MessageDescriptor.Restarting);
                }
                else if (mainRunningProject?.IsRestarting != true && !suppressWaitForFileChange)
                {
                    using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token);
                    await WaitForFileChangeBeforeRestarting(
                        fileWatcher,
                        evaluationResult,
                        projectGraph,
                        rootProjectsBuildResult?.Success == true,
                        shutdownOrForcedRestartSource.Token);
                }
            }
        }
    }

    private async Task<bool> RestartPrompt(IEnumerable<string> projectNames, IRuntimeProcessLauncher? runtimeProcessLauncher, CancellationToken cancellationToken)
    {
        if (_rudeEditRestartPrompt != null)
        {
            string question;
            if (runtimeProcessLauncher == null)
            {
                question = "Do you want to restart your app?";
            }
            else
            {
                _context.Logger.LogInformation("Affected projects:");

                foreach (var projectName in projectNames.Order())
                {
                    _context.Logger.LogInformation("  {ProjectName}", projectName);
                }

                question = "Do you want to restart these projects?";
            }

            return await _rudeEditRestartPrompt.WaitForRestartConfirmationAsync(question, cancellationToken);
        }

        _context.Logger.LogDebug("Restarting without prompt since dotnet-watch is running in non-interactive mode.");

        foreach (var projectName in projectNames)
        {
            _context.Logger.LogDebug("  Project to restart: '{ProjectName}'", projectName);
        }

        return true;
    }

    private void AnalyzeFileChanges(
        List<ChangedFile> changedFiles,
        EvaluationResult evaluationResult,
        out bool evaluationRequired)
    {
        // If any build file changed (project, props, targets) we need to re-evaluate the projects.
        // Currently we re-evaluate the whole project graph even if only a single project file changed.
        if (changedFiles.Select(f => f.Item.FilePath).FirstOrDefault(path => evaluationResult.ProjectGraph.BuildFiles.Contains(path) || MatchesBuildFile(path)) is { } firstBuildFilePath)
        {
            _context.Logger.Log(MessageDescriptor.ProjectChangeTriggeredReEvaluation, firstBuildFilePath);
            evaluationRequired = true;
            return;
        }

        for (var i = 0; i < changedFiles.Count; i++)
        {
            var changedFile = changedFiles[i];
            var filePath = changedFile.Item.FilePath;

            if (changedFile.Kind is ChangeKind.Add)
            {
                if (MatchesStaticWebAssetFilePattern(evaluationResult, filePath, out var staticWebAssetUrl))
                {
                    changedFiles[i] = changedFile with
                    {
                        Item = changedFile.Item with { StaticWebAssetRelativeUrl = staticWebAssetUrl }
                    };
                }
                else
                {
                    // TODO: https://github.com/dotnet/sdk/issues/52390
                    // Get patterns from evaluation that match Compile, AdditionalFile, AnalyzerConfigFile items.
                    // Avoid re-evaluating on addition of files that don't affect the project.

                    // project file or other file:
                    _context.Logger.Log(MessageDescriptor.FileAdditionTriggeredReEvaluation, filePath);
                    evaluationRequired = true;
                    return;
                }
            }
        }

        evaluationRequired = false;
    }

    /// <summary>
    /// True if the file path looks like a file that might be imported by MSBuild.
    /// </summary>
    private static bool MatchesBuildFile(string filePath)
    {
        var extension = Path.GetExtension(filePath);
        return extension.Equals(".props", PathUtilities.OSSpecificPathComparison)
            || extension.Equals(".targets", PathUtilities.OSSpecificPathComparison)
            || extension.EndsWith("proj", PathUtilities.OSSpecificPathComparison)
            || extension.Equals(".projitems", PathUtilities.OSSpecificPathComparison) // shared project items
            || string.Equals(Path.GetFileName(filePath), "global.json", PathUtilities.OSSpecificPathComparison);
    }

    /// <summary>
    /// Determines if the given file path is a static web asset file path based on
    /// the discovery patterns.
    /// </summary>
    private static bool MatchesStaticWebAssetFilePattern(EvaluationResult evaluationResult, string filePath, out string? staticWebAssetUrl)
    {
        staticWebAssetUrl = null;

        if (StaticWebAsset.IsScopedCssFile(filePath))
        {
            return true;
        }

        foreach (var (_, manifest) in evaluationResult.StaticWebAssetsManifests)
        {
            foreach (var pattern in manifest.DiscoveryPatterns)
            {
                var match = pattern.Glob.MatchInfo(filePath);
                if (match.IsMatch)
                {
                    var dirUrl = match.WildcardDirectoryPartMatchGroup.Replace(Path.DirectorySeparatorChar, '/');

                    Debug.Assert(!dirUrl.EndsWith('/'));
                    Debug.Assert(!pattern.BaseUrl.EndsWith('/'));

                    var url = UrlEncoder.Default.Encode(dirUrl + "/" + match.FilenamePartMatchGroup);
                    if (pattern.BaseUrl != "")
                    {
                        url = pattern.BaseUrl + "/" + url;
                    }

                    staticWebAssetUrl = url;
                    return true;
                }
            }
        }

        return false;
    }

    private async ValueTask DeployProjectDependenciesAsync(EvaluationResult evaluationResult, IEnumerable<string> projectPaths, CancellationToken cancellationToken)
    {
        const string TargetName = TargetNames.ReferenceCopyLocalPathsOutputGroup;

        var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer);

        var buildRequests = new List<BuildRequest<string>>();

        foreach (var (_, restoredProjectInstance) in evaluationResult.RestoredProjectInstances)
        {
            cancellationToken.ThrowIfCancellationRequested();

            // Avoid modification of the restored snapshot.
            var projectInstance = restoredProjectInstance.DeepCopy();

            var projectPath = projectInstance.FullPath;

            if (!projectPathSet.Contains(projectPath))
            {
                continue;
            }

            if (!projectInstance.Targets.ContainsKey(TargetName))
            {
                continue;
            }

            if (projectInstance.GetOutputDirectory() is not { } relativeOutputDir)
            {
                continue;
            }

            buildRequests.Add(BuildRequest.Create(projectInstance, [TargetName], relativeOutputDir));
        }

        var results = await evaluationResult.BuildManager.BuildAsync(
            buildRequests,
            onFailure: failedInstance =>
            {
                _context.Logger.LogDebug("[{ProjectName}] {TargetName} target failed", failedInstance.GetDisplayName(), TargetName);

                // continue build
                return true;
            },
            operationName: "DeployProjectDependencies",
            cancellationToken);

        var copyTasks = new List<Task>();

        foreach (var result in results)
        {
            if (!result.IsSuccess)
            {
                continue;
            }

            var relativeOutputDir = result.Data;
            var projectInstance = result.ProjectInstance;

            var projectPath = projectInstance.FullPath;

            var outputDir = Path.Combine(Path.GetDirectoryName(projectPath)!, relativeOutputDir);

            foreach (var item in result.TargetResults[TargetName].Items)
            {
                cancellationToken.ThrowIfCancellationRequested();

                var sourcePath = item.ItemSpec;
                var targetPath = Path.Combine(outputDir, item.GetMetadata(MetadataNames.TargetPath));

                copyTasks.Add(Task.Run(() =>
                {
                    if (!File.Exists(targetPath))
                    {
                        _context.Logger.LogDebug("Deploying project dependency '{TargetPath}' from '{SourcePath}'", targetPath, sourcePath);

                        try
                        {
                            var directory = Path.GetDirectoryName(targetPath);
                            if (directory != null)
                            {
                                Directory.CreateDirectory(directory);
                            }

                            File.Copy(sourcePath, targetPath, overwrite: false);
                        }
                        catch (Exception e)
                        {
                            _context.Logger.LogDebug("Copy failed: {Message}", e.Message);
                        }
                    }
                }, cancellationToken));
            }
        }

        await Task.WhenAll(copyTasks);
    }

    private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatcher, EvaluationResult? evaluationResult, LoadedProjectGraph? projectGraph, bool buildSucceeded, CancellationToken cancellationToken)
    {
        var messageDescriptor = buildSucceeded ? MessageDescriptor.WaitingForFileChangeBeforeRestarting : MessageDescriptor.FixBuildError;

        if (evaluationResult != null)
        {
            _ = await fileWatcher.WaitForFileChangeAsync(
                evaluationResult.Files,
                startedWatching: () => _context.Logger.Log(messageDescriptor),
                cancellationToken);
        }
        else
        {
            // build failed or was cancelled - watch for any changes in the directory trees containing root projects or entry-point files:
            if (projectGraph == null)
            {
                fileWatcher.WatchContainingDirectories(_context.RootProjects.Select(p => p.ProjectOrEntryPointFilePath), includeSubdirectories: true);
            }

            _ = await fileWatcher.WaitForFileChangeAsync(
                acceptChange: AcceptChange,
                startedWatching: () => _context.Logger.Log(messageDescriptor),
                cancellationToken);
        }
    }

    private bool AcceptChange(ChangedPath change, EvaluationResult evaluationResult)
    {
        var (path, kind) = change;

        // Handle changes to files that are known to be project build inputs from its evaluation.
        // Compile items might be explicitly added by targets to directories that are excluded by default
        // (e.g. global usings in obj directory). Changes to these files should not be ignored.
        if (evaluationResult.Files.ContainsKey(path))
        {
            return true;
        }

        if (!AcceptChange(change))
        {
            return false;
        }

        // changes in *.*proj, *.props, *.targets:
        if (evaluationResult.ProjectGraph.BuildFiles.Contains(path))
        {
            return true;
        }

        // Ignore other changes that match DefaultItemExcludes glob if EnableDefaultItems is true,
        // otherwise changes under output and intermediate output directories.
        //
        // Unsupported scenario:
        // - msbuild target adds source files to intermediate output directory and Compile items
        //   based on the content of non-source file.
        //
        // On the other hand, changes to source files produced by source generators will be registered
        // since the changes to additional file will trigger workspace update, which will trigger the source generator.
        return !evaluationResult.ItemExclusions.IsExcluded(path, kind, _context.Logger);
    }

    private bool AcceptChange(ChangedPath change)
    {
        var (path, kind) = change;

        if (Path.GetExtension(path) == ".binlog")
        {
            return false;
        }

        if (PathUtilities.GetContainingDirectories(path).FirstOrDefault(IsHiddenDirectory) is { } containingHiddenDir)
        {
            _context.Logger.Log(MessageDescriptor.IgnoringChangeInHiddenDirectory, containingHiddenDir, kind, path);
            return false;
        }

        return true;
    }

    // Directory name starts with '.' on Unix is considered hidden.
    // Apply the same convention on Windows as well (instead of checking for hidden attribute).
    // This is consistent with SDK rules for default item exclusions:
    // https://github.com/dotnet/sdk/blob/124be385f90f2c305dde2b817cb470e4d11d2d6b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.DefaultItems.targets#L42
    private static bool IsHiddenDirectory(string dir)
        => Path.GetFileName(dir).StartsWith('.');

    internal static IEnumerable<ChangedPath> NormalizePathChanges(IEnumerable<ChangedPath> changes)
        => changes
            .GroupBy(keySelector: change => change.Path)
            .Select(group =>
            {
                ChangedPath? lastUpdate = null;
                ChangedPath? lastDelete = null;
                ChangedPath? lastAdd = null;
                ChangedPath? previous = null;

                foreach (var item in group)
                {
                    // eliminate repeated changes:
                    if (item.Kind == previous?.Kind)
                    {
                        continue;
                    }

                    previous = item;

                    if (item.Kind == ChangeKind.Add)
                    {
                        // eliminate delete-(update)*-add:
                        if (lastDelete.HasValue)
                        {
                            lastDelete = null;
                            lastAdd = null;
                            lastUpdate ??= item with { Kind = ChangeKind.Update };
                        }
                        else
                        {
                            lastAdd = item;
                        }
                    }
                    else if (item.Kind == ChangeKind.Delete)
                    {
                        // eliminate add-delete:
                        if (lastAdd.HasValue)
                        {
                            lastDelete = null;
                            lastAdd = null;
                        }
                        else
                        {
                            lastDelete = item;

                            // eliminate previous update:
                            lastUpdate = null;
                        }
                    }
                    else if (item.Kind == ChangeKind.Update)
                    {
                        // ignore updates after add:
                        if (!lastAdd.HasValue)
                        {
                            lastUpdate = item;
                        }
                    }
                    else
                    {
                        throw new InvalidOperationException($"Unexpected change kind: {item.Kind}");
                    }
                }

                return lastDelete ?? lastAdd ?? lastUpdate;
            })
            .Where(item => item != null)
            .Select(item => item!.Value);

    private void ReportFileChanges(IReadOnlyList<ChangedFile> changedFiles)
    {
        Report(kind: ChangeKind.Add);
        Report(kind: ChangeKind.Update);
        Report(kind: ChangeKind.Delete);

        void Report(ChangeKind kind)
        {
            var items = changedFiles.Where(item => item.Kind == kind).ToArray();
            if (items is not [])
            {
                _context.Logger.LogInformation(GetMessage(items, kind));
            }
        }

        string GetMessage(IReadOnlyList<ChangedFile> items, ChangeKind kind)
            => items is [{Item: var item }]
                ? GetSingularMessage(kind) + ": " + GetRelativeFilePath(item.FilePath)
                : GetPluralMessage(kind) + ": " + string.Join(", ", items.Select(f => GetRelativeFilePath(f.Item.FilePath)));

        static string GetSingularMessage(ChangeKind kind)
            => kind switch
            {
                ChangeKind.Update => "File updated",
                ChangeKind.Add => "File added",
                ChangeKind.Delete => "File deleted",
                _ => throw new InvalidOperationException()
            };

        static string GetPluralMessage(ChangeKind kind)
            => kind switch
            {
                ChangeKind.Update => "Files updated",
                ChangeKind.Add => "Files added",
                ChangeKind.Delete => "Files deleted",
                _ => throw new InvalidOperationException()
            };
    }

    private LoadedProjectGraph? TryLoadProjectGraph(string? virtualProjectTargetFramework, CancellationToken cancellationToken)
        => _designTimeBuildGraphFactory.TryLoadProjectGraph(projectGraphRequired: true, virtualProjectTargetFramework, cancellationToken);

    private ValueTask<EvaluationResult?> TryEvaluateProjectGraphAsync(LoadedProjectGraph projectGraph, string? mainProjectTargetFramework, bool restore, CancellationToken cancellationToken)
        => EvaluationResult.TryCreateAsync(
            projectGraph,
            _context.BuildLogger,
            _context.Options,
            _context.EnvironmentOptions,
            mainProjectTargetFramework,
            restore,
            cancellationToken);

    private enum BuildAction
    {
        RestoreOnly,
        RestoreAndBuild,
        BuildOnly,
    }

    // internal for testing
    internal sealed class BuildProjectsResult(string? mainProjectTargetFramework, string? selectedDevice, string? selectedDeviceRuntimeIdentifier, LoadedProjectGraph? projectGraph, bool success)
    {
        public string? MainProjectTargetFramework { get; } = mainProjectTargetFramework;
        public string? SelectedDevice { get; } = selectedDevice;
        public string? SelectedDeviceRuntimeIdentifier { get; } = selectedDeviceRuntimeIdentifier;
        public LoadedProjectGraph? ProjectGraph { get; } = projectGraph;
        public bool Success { get; } = success;
    }

    // internal for testing
    internal async Task<BuildProjectsResult> BuildProjectsAsync(
        ImmutableArray<ProjectRepresentation> projects,
        FileWatcher fileWatcher,
        ProjectOptions? mainProjectOptions,
        Func<IReadOnlyList<string>, CancellationToken, ValueTask<string>>? frameworkSelector,
        Func<IReadOnlyList<DeviceInfo>, CancellationToken, ValueTask<DeviceInfo>>? deviceSelector,
        CancellationToken cancellationToken)
    {
        Debug.Assert(projects.Any());

        LoadedProjectGraph? projectGraph = null;
        var targetFramework = mainProjectOptions?.TargetFramework;
        var selectedDevice = mainProjectOptions?.Device;
        var selectedDeviceRuntimeIdentifier = mainProjectOptions?.DeviceRuntimeIdentifier;

        _context.Logger.Log(MessageDescriptor.BuildStartedNotification, projects);

        // pause accumulating file changes during build:
        fileWatcher.SuppressEvents = true;
        try
        {
            var success = await BuildWithFrameworkAndDeviceSelectionAsync();
            _context.Logger.Log(MessageDescriptor.BuildCompletedNotification, (projects, success));
            return new BuildProjectsResult(targetFramework, selectedDevice, selectedDeviceRuntimeIdentifier, projectGraph, success);
        }
        finally
        {
            fileWatcher.SuppressEvents = false;
        }

        async ValueTask<bool> BuildWithFrameworkAndDeviceSelectionAsync()
        {
            // Framework selection for file-based programs:
            // If framework is specified on command line use it to create the virtual project.
            // Otherwise, use TargetFramework/TargetFrameworks property specified in the source file, if any.
            //
            // Device selection not applicable to file based apps.
            if (mainProjectOptions?.Representation.EntryPointFilePath is { } sourcePath)
            {
                if (targetFramework == null)
                {
                    if (VirtualProjectBuilder.GetPropertyFromSourceFile(sourcePath, PropertyNames.TargetFramework) is { } framework and not "")
                    {
                        targetFramework = framework;
                    }
                    else if (VirtualProjectBuilder.GetPropertyFromSourceFile(sourcePath, PropertyNames.TargetFrameworks) is { } frameworks and not "")
                    {
                        var frameworkList = frameworks.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

                        if (frameworkSelector != null)
                        {
                            targetFramework = await frameworkSelector(frameworkList, cancellationToken);
                        }
                        else
                        {
                            _context.BuildLogger.Log(MessageDescriptor.FileSpecifiesMultipleTargetFrameworks, sourcePath, string.Join("', '", frameworkList));

                            return false;
                        }
                    }
                }

                return await BuildAsync(BuildAction.RestoreAndBuild, targetFramework);
            }

            var needsDeviceSelection = selectedDevice == null && deviceSelector != null;
            var needsFrameworkSelection = targetFramework == null && frameworkSelector != null;

            if (mainProjectOptions == null ||
                (!needsFrameworkSelection && !needsDeviceSelection))
            {
                return await BuildAsync(BuildAction.RestoreAndBuild, targetFramework);
            }

            if (!await BuildAsync(BuildAction.RestoreOnly, targetFramework: null))
            {
                return false;
            }

            // load project graph after restore so that props and targets files from packages are imported:
            projectGraph = TryLoadProjectGraph(targetFramework, cancellationToken);
            if (projectGraph == null)
            {
                return false;
            }

            var rootProject = projectGraph.Graph.GraphRoots.Single().ProjectInstance;

            // Select target framework if needed:
            if (needsFrameworkSelection)
            {
                Debug.Assert(frameworkSelector != null);

                if (rootProject.GetTargetFramework() is var framework and not "")
                {
                    targetFramework = framework;
                }
                else if (rootProject.GetTargetFrameworks() is var frameworks and not [])
                {
                    targetFramework = await frameworkSelector(frameworks, cancellationToken);
                }
                else
                {
                    _context.BuildLogger.LogError("Project '{Path}' does not specify a target framework.", rootProject.FullPath);
                    return false;
                }
            }

            // Select device if needed:
            if (needsDeviceSelection
                && rootProject.Targets.ContainsKey(TargetNames.ComputeAvailableDevices))
            {
                Debug.Assert(deviceSelector != null);

                var deviceInfo = await TrySelectDeviceAsync(projectGraph, rootProject, targetFramework, deviceSelector, cancellationToken);
                if (deviceInfo == null)
                {
                    return false;
                }

                selectedDevice = deviceInfo.Id;
                selectedDeviceRuntimeIdentifier = deviceInfo.RuntimeIdentifier;
                _context.Logger.LogDebug("Selected device: {DeviceId}", selectedDevice);

                // If the device provides a RuntimeIdentifier, re-restore so the assets file
                // includes the RID target. This mirrors the dotnet-run behavior.
                if (!string.IsNullOrEmpty(selectedDeviceRuntimeIdentifier))
                {
                    if (!await BuildAsync(BuildAction.RestoreOnly, targetFramework, deviceInfo))
                    {
                        return false;
                    }
                }
            }

            return await BuildAsync(BuildAction.BuildOnly, targetFramework, selectedDevice != null ? new DeviceInfo(selectedDevice, null, null, null, selectedDeviceRuntimeIdentifier) : null);
        }

        async Task<bool> BuildAsync(BuildAction action, string? targetFramework, DeviceInfo? device = null)
        {
            if (projects is [var singleProject])
            {
                return await BuildFileOrProjectOrSolutionAsync(singleProject.ProjectOrEntryPointFilePath, targetFramework, device, action, cancellationToken);
            }

            // TODO: workaround for https://github.com/dotnet/sdk/issues/51311

            var projectPaths = projects.Where(p => p.PhysicalPath != null).Select(p => p.PhysicalPath!).ToArray();

            if (projectPaths is [var singleProjectPath])
            {
                if (!await BuildFileOrProjectOrSolutionAsync(singleProjectPath, targetFramework, device, action, cancellationToken))
                {
                    return false;
                }
            }
            else if (projectPaths is not [])
            {
                var solutionFile = Path.Combine(Path.GetTempFileName() + ".slnx");
                var solutionElement = new XElement("Solution");

                foreach (var projectPath in projectPaths)
                {
                    solutionElement.Add(new XElement("Project", new XAttribute("Path", projectPath)));
                }

                var doc = new XDocument(solutionElement);
                doc.Save(solutionFile);

                try
                {
                    if (!await BuildFileOrProjectOrSolutionAsync(solutionFile, targetFramework, device, action, cancellationToken))
                    {
                        return false;
                    }
                }
                finally
                {
                    try
                    {
                        File.Delete(solutionFile);
                    }
                    catch
                    {
                        // ignore
                    }
                }
            }

            // To maximize parallelism of building dependencies, build file-based projects after all physical projects:
            foreach (var file in projects.Where(p => p.EntryPointFilePath != null).Select(p => p.EntryPointFilePath!))
            {
                if (!await BuildFileOrProjectOrSolutionAsync(file, targetFramework, device, action, cancellationToken))
                {
                    return false;
                }
            }

            return true;
        }
    }

    /// <summary>
    /// Computes available devices and selects one.
    /// Auto-selects a single device when only one is available; otherwise uses the provided device selector.
    /// Returns null if no devices are available (error).
    /// </summary>
    private async Task<DeviceInfo?> TrySelectDeviceAsync(
        LoadedProjectGraph projectGraph,
        ProjectInstance rootProject,
        string? targetFramework,
        Func<IReadOnlyList<DeviceInfo>, CancellationToken, ValueTask<DeviceInfo>> deviceSelector,
        CancellationToken cancellationToken)
    {
        // Get the project node for the selected TFM so device computation is correct.
        var projectNode = projectGraph.TryGetProjectNode(rootProject.FullPath, targetFramework);
        if (projectNode == null)
        {
            return null;
        }

        var projectInstance = projectNode.ProjectInstance.DeepCopy();

        var results = await projectGraph.BuildManager.BuildAsync(
            [BuildRequest.Create(projectInstance, [TargetNames.ComputeAvailableDevices])],
            onFailure: _ => true,
            operationName: TargetNames.ComputeAvailableDevices,
            cancellationToken);

        if (results is not [var result] || !result.IsSuccess
            || !result.TargetResults.TryGetValue(TargetNames.ComputeAvailableDevices, out var targetResult))
        {
            _context.Logger.LogDebug("ComputeAvailableDevices target failed or returned no output.");
            return null;
        }

        var devices = new List<DeviceInfo>(targetResult.Items.Length);
        foreach (var item in targetResult.Items)
        {
            devices.Add(new DeviceInfo(
                item.ItemSpec,
                item.GetMetadata("Description"),
                item.GetMetadata("Type"),
                item.GetMetadata("Status"),
                item.GetMetadata("RuntimeIdentifier")));
        }

        if (devices.Count == 0)
        {
            _context.Logger.Log(MessageDescriptor.NoDevicesAvailable);
            return null;
        }

        // Auto-select if only one device is available.
        if (devices.Count == 1)
        {
            return devices[0];
        }

        return await deviceSelector(devices, cancellationToken);
    }

    private async Task<bool> BuildFileOrProjectOrSolutionAsync(string path, string? targetFramework, DeviceInfo? device, BuildAction action, CancellationToken cancellationToken)
    {
        var arguments = new List<string>
        {
            action is BuildAction.RestoreOnly ? "restore" : "build",
            path
        };

        arguments.AddRange(_context.BuildArguments);

        if (action != BuildAction.RestoreOnly && targetFramework != null)
        {
            arguments.Add("--framework");
            arguments.Add(targetFramework);
        }

        if (device != null)
        {
            arguments.Add($"-p:Device={device.Id}");

            if (!string.IsNullOrEmpty(device.RuntimeIdentifier))
            {
                arguments.Add("--runtime");
                arguments.Add(device.RuntimeIdentifier);
            }
        }

        if (action == BuildAction.BuildOnly)
        {
            arguments.Add("--no-restore");
        }
        else if (action == BuildAction.RestoreOnly)
        {
            arguments.Add("-consoleLoggerParameters:NoSummary");
        }

        List<OutputLine>? capturedOutput = _context.EnvironmentOptions.TestFlags != TestFlags.None ? [] : null;
        var processSpec = new ProcessSpec
        {
            Executable = _context.EnvironmentOptions.GetMuxerPath(),
            WorkingDirectory = Path.GetDirectoryName(path),
            IsUserApplication = false,

            // Capture output if running in a test environment.
            // If the output is not captured dotnet build will show live build progress.
            OnOutput = capturedOutput != null
                ? line =>
                {
                    lock (capturedOutput)
                    {
                        capturedOutput.Add(line);
                    }
                }
                : null,

            // pass user-specified build arguments last to override defaults:
            Arguments = arguments
        };

        _context.BuildLogger.Log(action == BuildAction.RestoreOnly ? MessageDescriptor.Restoring : MessageDescriptor.Building, path);

        var success = await _context.ProcessRunner.RunAsync(processSpec, _context.Logger, launchResult: null, cancellationToken) == 0;

        if (capturedOutput != null)
        {
            // To avoid multiple status messages, only log the status if the output of `dotnet build` is not being streamed to the console:
            _context.BuildLogger.Log(
                (action, success) switch
                {
                    (BuildAction.RestoreOnly, true) => MessageDescriptor.RestoreSucceeded,
                    (BuildAction.RestoreOnly, false) => MessageDescriptor.RestoreFailed,
                    (_, true) => MessageDescriptor.BuildSucceeded,
                    (_, false) => MessageDescriptor.BuildFailed,
                },
                path);

            BuildOutput.ReportBuildOutput(_context.BuildLogger, capturedOutput, success);
        }

        return success;
    }

    private string GetRelativeFilePath(string path)
    {
        var relativePath = path;
        var workingDirectory = _context.EnvironmentOptions.WorkingDirectory;
        if (path.StartsWith(workingDirectory, StringComparison.Ordinal) && path.Length > workingDirectory.Length)
        {
            relativePath = path.Substring(workingDirectory.Length);

            return $".{(relativePath.StartsWith(Path.DirectorySeparatorChar) ? string.Empty : Path.DirectorySeparatorChar)}{relativePath}";
        }

        return relativePath;
    }
}