File: Watch\DotNetWatcher.cs
Web Access
Project: src\src\sdk\src\Dotnet.Watch\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.Diagnostics;
using System.Globalization;
using Microsoft.Build.Graph;
using Microsoft.Extensions.Logging;

namespace Microsoft.DotNet.Watch;

internal static class DotNetWatcher
{
    public static async Task WatchAsync(DotNetWatchContext context, CancellationToken shutdownCancellationToken)
    {
        var cancelledTaskSource = new TaskCompletionSource();
        shutdownCancellationToken.Register(state => ((TaskCompletionSource)state!).TrySetResult(),
            cancelledTaskSource);

        if (context.EnvironmentOptions.SuppressMSBuildIncrementalism)
        {
            context.Logger.LogDebug("MSBuild incremental optimizations suppressed.");
        }

        var environmentBuilder = new Dictionary<string, string>();

        ChangedFile? changedFile = null;
        var buildEvaluator = new BuildEvaluator(context);

        for (var iteration = 0;;iteration++)
        {
            if (await buildEvaluator.EvaluateAsync(changedFile, shutdownCancellationToken) is not { } evaluationResult)
            {
                context.Logger.LogError("Failed to find a list of files to watch");
                return;
            }

            StaticFileHandler? staticFileHandler;
            ProjectGraphNode? projectRootNode;
            if (evaluationResult.ProjectGraph != null)
            {
                projectRootNode = evaluationResult.ProjectGraph.Graph.GraphRoots.Single();
                staticFileHandler = new StaticFileHandler(context.Logger, evaluationResult.ProjectGraph, context.BrowserRefreshServerFactory);
            }
            else
            {
                context.Logger.LogDebug("Unable to determine if this project is a webapp.");
                projectRootNode = null;
                staticFileHandler = null;
            }

            var processSpec = new ProcessSpec
            {
                Executable = context.EnvironmentOptions.GetMuxerPath(),
                WorkingDirectory = context.EnvironmentOptions.WorkingDirectory,
                IsUserApplication = true,
                Arguments = buildEvaluator.GetProcessArguments(iteration),
                EnvironmentVariables =
                {
                    [EnvironmentVariables.Names.DotnetWatch] = "1",
                    [EnvironmentVariables.Names.DotnetWatchIteration] = (iteration + 1).ToString(CultureInfo.InvariantCulture),
                }
            };

            var browserRefreshServer = projectRootNode != null && HotReloadAppModel.InferFromProject(context, projectRootNode) is WebApplicationAppModel webAppModel
                ? await context.BrowserRefreshServerFactory.GetOrCreateBrowserRefreshServerAsync(projectRootNode, webAppModel, shutdownCancellationToken)
                : null;

            browserRefreshServer?.ConfigureLaunchEnvironment(environmentBuilder, enableHotReload: false);

            Action<OutputLine>? outputObserver = null;
            if (projectRootNode != null)
            {
                Debug.Assert(context.MainProjectOptions != null);
                outputObserver = context.BrowserLauncher.TryGetBrowserLaunchOutputObserver(projectRootNode, context.MainProjectOptions, browserRefreshServer, shutdownCancellationToken);
            }

            processSpec.RedirectOutput(outputObserver, context.ProcessOutputReporter, context.EnvironmentOptions, projectRootNode?.GetDisplayName() ?? "");

            foreach (var (name, value) in environmentBuilder)
            {
                processSpec.EnvironmentVariables.Add(name, value);
            }

            // Reset for next run
            buildEvaluator.RequiresRevaluation = false;

            if (shutdownCancellationToken.IsCancellationRequested)
            {
                return;
            }

            using var currentRunCancellationSource = new CancellationTokenSource();
            using var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, currentRunCancellationSource.Token);
            using var fileSetWatcher = new FileWatcher(context.Logger, context.EnvironmentOptions);

            fileSetWatcher.WatchContainingDirectories(evaluationResult.Files.Keys, includeSubdirectories: true);

            var processTask = context.ProcessRunner.RunAsync(processSpec, context.Logger, launchResult: null, combinedCancellationSource.Token);

            Task<ChangedFile?> fileSetTask;
            Task finishedTask;

            context.Logger.Log(MessageDescriptor.WaitingForChanges);

            while (true)
            {
                fileSetTask = fileSetWatcher.WaitForFileChangeAsync(evaluationResult.Files, startedWatching: null, combinedCancellationSource.Token);
                finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task);

                if (staticFileHandler != null && finishedTask == fileSetTask && fileSetTask.Result.HasValue)
                {
                    if (await staticFileHandler.HandleFileChangesAsync([fileSetTask.Result.Value], combinedCancellationSource.Token))
                    {
                        // We're able to handle the file change event without doing a full-rebuild.
                        continue;
                    }
                }

                break;
            }

            // Regardless of the which task finished first, make sure everything is cancelled
            // and wait for dotnet to exit. We don't want orphan processes
            currentRunCancellationSource.Cancel();

            await Task.WhenAll(processTask, fileSetTask);

            if (finishedTask == cancelledTaskSource.Task || shutdownCancellationToken.IsCancellationRequested)
            {
                return;
            }

            if (finishedTask == processTask)
            {
                // Process exited. Redo evalulation
                buildEvaluator.RequiresRevaluation = true;

                // Now wait for a file to change before restarting process
                changedFile = await fileSetWatcher.WaitForFileChangeAsync(
                    evaluationResult.Files,
                    startedWatching: () => context.Logger.Log(MessageDescriptor.WaitingForFileChangeBeforeRestarting),
                    shutdownCancellationToken);
            }
            else
            {
                Debug.Assert(finishedTask == fileSetTask);
                changedFile = fileSetTask.Result;
                Debug.Assert(changedFile != null, "ChangedFile should only be null when cancelled");
                context.Logger.LogInformation("File changed: {Path}", changedFile.Value.Item.FilePath);
            }
        }
    }
}