From 860360dbb024f76ce06aba069a1b424c6d7cf661 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Fri, 7 Nov 2025 11:33:19 -0800 Subject: [PATCH 1/2] Reuse ProjectCollection across DTBs --- .../dotnet-watch/Build/EvaluationResult.cs | 30 +++---- .../dotnet-watch/Build/ProjectGraphFactory.cs | 80 +++++++++++++++++++ .../Build/ProjectGraphUtilities.cs | 68 ---------------- .../HotReload/HotReloadDotNetWatcher.cs | 10 ++- .../Watch/MsBuildFileSetFactory.cs | 8 +- .../HotReload/CompilationHandlerTests.cs | 3 +- 6 files changed, 110 insertions(+), 89 deletions(-) create mode 100644 src/BuiltInTools/dotnet-watch/Build/ProjectGraphFactory.cs diff --git a/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs b/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs index 966ea12c87c4..d4090ce69840 100644 --- a/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs +++ b/src/BuiltInTools/dotnet-watch/Build/EvaluationResult.cs @@ -32,12 +32,26 @@ public void WatchFiles(FileWatcher fileWatcher) fileWatcher.WatchFiles(BuildFiles); } + public static ImmutableDictionary GetGlobalBuildOptions(IEnumerable buildArguments, EnvironmentOptions environmentOptions) + { + // See https://github.com/dotnet/project-system/blob/main/docs/well-known-project-properties.md + + return CommandLineOptions.ParseBuildProperties(buildArguments) + .ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value) + .SetItem(PropertyNames.DotNetWatchBuild, "true") + .SetItem(PropertyNames.DesignTimeBuild, "true") + .SetItem(PropertyNames.SkipCompilerExecution, "true") + .SetItem(PropertyNames.ProvideCommandLineArgs, "true") + // F# targets depend on host path variable: + .SetItem("DOTNET_HOST_PATH", environmentOptions.MuxerPath); + } + /// /// Loads project graph and performs design-time build. /// public static EvaluationResult? TryCreate( + ProjectGraphFactory factory, string rootProjectPath, - IEnumerable buildArguments, ILogger logger, GlobalOptions options, EnvironmentOptions environmentOptions, @@ -46,20 +60,8 @@ public void WatchFiles(FileWatcher fileWatcher) { var buildReporter = new BuildReporter(logger, options, environmentOptions); - // See https://github.com/dotnet/project-system/blob/main/docs/well-known-project-properties.md - - var globalOptions = CommandLineOptions.ParseBuildProperties(buildArguments) - .ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value) - .SetItem(PropertyNames.DotNetWatchBuild, "true") - .SetItem(PropertyNames.DesignTimeBuild, "true") - .SetItem(PropertyNames.SkipCompilerExecution, "true") - .SetItem(PropertyNames.ProvideCommandLineArgs, "true") - // F# targets depend on host path variable: - .SetItem("DOTNET_HOST_PATH", environmentOptions.MuxerPath); - - var projectGraph = ProjectGraphUtilities.TryLoadProjectGraph( + var projectGraph = factory.TryLoadProjectGraph( rootProjectPath, - globalOptions, logger, projectGraphRequired: true, cancellationToken); diff --git a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphFactory.cs b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphFactory.cs new file mode 100644 index 000000000000..520bd4b8f972 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphFactory.cs @@ -0,0 +1,80 @@ +// 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 Microsoft.Build.Evaluation; +using Microsoft.Build.Graph; +using Microsoft.Extensions.Logging; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Microsoft.DotNet.Watch; + +internal sealed class ProjectGraphFactory(ImmutableDictionary globalOptions) +{ + /// + /// Reuse with XML element caching to improve performance. + /// + /// The cache is automatically updated when build files change. + /// https://github.com/dotnet/msbuild/blob/b6f853defccd64ae1e9c7cf140e7e4de68bff07c/src/Build/Definition/ProjectCollection.cs#L343-L354 + /// + private readonly ProjectCollection _collection = new( + globalProperties: globalOptions, + loggers: [], + remoteLoggers: [], + ToolsetDefinitionLocations.Default, + maxNodeCount: 1, + onlyLogCriticalEvents: false, + loadProjectsReadOnly: false, + useAsynchronousLogging: false, + reuseProjectRootElementCache: true); + + /// + /// Tries to create a project graph by running the build evaluation phase on the . + /// + public ProjectGraph? TryLoadProjectGraph( + string rootProjectFile, + ILogger logger, + bool projectGraphRequired, + CancellationToken cancellationToken) + { + var entryPoint = new ProjectGraphEntryPoint(rootProjectFile, globalOptions); + try + { + return new ProjectGraph([entryPoint], _collection, projectInstanceFactory: null, cancellationToken); + } + catch (Exception e) when (e is not OperationCanceledException) + { + // ProejctGraph aggregates OperationCanceledException exception, + // throw here to propagate the cancellation. + cancellationToken.ThrowIfCancellationRequested(); + + logger.LogDebug("Failed to load project graph."); + + if (e is AggregateException { InnerExceptions: var innerExceptions }) + { + foreach (var inner in innerExceptions) + { + Report(inner); + } + } + else + { + Report(e); + } + + void Report(Exception e) + { + if (projectGraphRequired) + { + logger.LogError(e.Message); + } + else + { + logger.LogWarning(e.Message); + } + } + } + + return null; + } +} diff --git a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs index 56bcba3427e6..6cb4c6b57a04 100644 --- a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs +++ b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs @@ -1,82 +1,14 @@ // 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 Microsoft.Build.Evaluation; using Microsoft.Build.Execution; using Microsoft.Build.Graph; using Microsoft.DotNet.Cli; -using Microsoft.Extensions.Logging; -using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Microsoft.DotNet.Watch; internal static class ProjectGraphUtilities { - /// - /// Tries to create a project graph by running the build evaluation phase on the . - /// - public static ProjectGraph? TryLoadProjectGraph( - string rootProjectFile, - ImmutableDictionary globalOptions, - ILogger logger, - bool projectGraphRequired, - CancellationToken cancellationToken) - { - var entryPoint = new ProjectGraphEntryPoint(rootProjectFile, globalOptions); - try - { - // Create a new project collection that does not reuse element cache - // to work around https://github.com/dotnet/msbuild/issues/12064: - var collection = new ProjectCollection( - globalProperties: globalOptions, - loggers: [], - remoteLoggers: [], - ToolsetDefinitionLocations.Default, - maxNodeCount: 1, - onlyLogCriticalEvents: false, - loadProjectsReadOnly: false, - useAsynchronousLogging: false, - reuseProjectRootElementCache: false); - - return new ProjectGraph([entryPoint], collection, projectInstanceFactory: null, cancellationToken); - } - catch (Exception e) when (e is not OperationCanceledException) - { - // ProejctGraph aggregates OperationCanceledException exception, - // throw here to propagate the cancellation. - cancellationToken.ThrowIfCancellationRequested(); - - logger.LogDebug("Failed to load project graph."); - - if (e is AggregateException { InnerExceptions: var innerExceptions }) - { - foreach (var inner in innerExceptions) - { - Report(inner); - } - } - else - { - Report(e); - } - - void Report(Exception e) - { - if (projectGraphRequired) - { - logger.LogError(e.Message); - } - else - { - logger.LogWarning(e.Message); - } - } - } - - return null; - } - public static string GetDisplayName(this ProjectGraphNode projectNode) => $"{Path.GetFileNameWithoutExtension(projectNode.ProjectInstance.FullPath)} ({projectNode.GetTargetFramework()})"; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs index 565bc7f9062b..5466d27b32c2 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs @@ -20,6 +20,7 @@ internal sealed class HotReloadDotNetWatcher private readonly RestartPrompt? _rudeEditRestartPrompt; private readonly DotNetWatchContext _context; + private readonly ProjectGraphFactory _designTimeBuildGraphFactory; internal Task? Test_FileChangesCompletedTask { get; set; } @@ -40,6 +41,11 @@ public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRun _rudeEditRestartPrompt = new RestartPrompt(context.Logger, consoleInput, noPrompt ? true : null); } + + _designTimeBuildGraphFactory = new ProjectGraphFactory( + EvaluationResult.GetGlobalBuildOptions( + context.RootProjectOptions.BuildArguments, + context.EnvironmentOptions)); } public async Task WatchAsync(CancellationToken shutdownCancellationToken) @@ -824,8 +830,8 @@ private async ValueTask EvaluateRootProjectAsync(bool restore, var stopwatch = Stopwatch.StartNew(); var result = EvaluationResult.TryCreate( - _context.RootProjectOptions.ProjectPath, - _context.RootProjectOptions.BuildArguments, + _designTimeBuildGraphFactory, + _context.RootProjectOptions.ProjectPath, _context.BuildLogger, _context.Options, _context.EnvironmentOptions, diff --git a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs index 6de60206c1d2..8f4887dd411d 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs @@ -30,6 +30,9 @@ internal class MSBuildFileSetFactory( private EnvironmentOptions EnvironmentOptions => buildReporter.EnvironmentOptions; private ILogger Logger => buildReporter.Logger; + private readonly ProjectGraphFactory _designTimeBuildGraphFactory = new( + globalOptions: CommandLineOptions.ParseBuildProperties(buildArguments).ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value)); + internal sealed class EvaluationResult(IReadOnlyDictionary files, ProjectGraph? projectGraph) { public readonly IReadOnlyDictionary Files = files; @@ -124,10 +127,7 @@ void AddFile(string filePath, string? staticWebAssetPath) ProjectGraph? projectGraph = null; if (requireProjectGraph != null) { - var globalOptions = CommandLineOptions.ParseBuildProperties(buildArguments) - .ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value); - - projectGraph = ProjectGraphUtilities.TryLoadProjectGraph(rootProjectFile, globalOptions, Logger, requireProjectGraph.Value, cancellationToken); + projectGraph = _designTimeBuildGraphFactory.TryLoadProjectGraph(rootProjectFile, Logger, requireProjectGraph.Value, cancellationToken); if (projectGraph == null && requireProjectGraph == true) { return null; diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index 9ac5030c4eba..4081a1e2102c 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -24,7 +24,8 @@ public async Task ReferenceOutputAssembly_False() var reporter = new TestReporter(Logger); var loggerFactory = new LoggerFactory(reporter); var logger = loggerFactory.CreateLogger("Test"); - var projectGraph = ProjectGraphUtilities.TryLoadProjectGraph(options.ProjectPath, globalOptions: [], logger, projectGraphRequired: false, CancellationToken.None); + var factory = new ProjectGraphFactory(globalOptions: []); + var projectGraph = factory.TryLoadProjectGraph(options.ProjectPath, logger, projectGraphRequired: false, CancellationToken.None); var handler = new CompilationHandler(logger, processRunner); await handler.Workspace.UpdateProjectConeAsync(hostProject, CancellationToken.None); From 96205f85e781d4e6e2db6944ba3e7bf7a9ffd4c6 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 10 Nov 2025 13:41:21 -0800 Subject: [PATCH 2/2] Feedback --- src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs index 8f4887dd411d..50ce65d4e9a1 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs @@ -30,7 +30,7 @@ internal class MSBuildFileSetFactory( private EnvironmentOptions EnvironmentOptions => buildReporter.EnvironmentOptions; private ILogger Logger => buildReporter.Logger; - private readonly ProjectGraphFactory _designTimeBuildGraphFactory = new( + private readonly ProjectGraphFactory _buildGraphFactory = new( globalOptions: CommandLineOptions.ParseBuildProperties(buildArguments).ToImmutableDictionary(keySelector: arg => arg.key, elementSelector: arg => arg.value)); internal sealed class EvaluationResult(IReadOnlyDictionary files, ProjectGraph? projectGraph) @@ -127,7 +127,7 @@ void AddFile(string filePath, string? staticWebAssetPath) ProjectGraph? projectGraph = null; if (requireProjectGraph != null) { - projectGraph = _designTimeBuildGraphFactory.TryLoadProjectGraph(rootProjectFile, Logger, requireProjectGraph.Value, cancellationToken); + projectGraph = _buildGraphFactory.TryLoadProjectGraph(rootProjectFile, Logger, requireProjectGraph.Value, cancellationToken); if (projectGraph == null && requireProjectGraph == true) { return null;