diff --git a/MSBuild.sln b/MSBuild.sln index 966817afd12..f58cad8b0d6 100644 --- a/MSBuild.sln +++ b/MSBuild.sln @@ -67,6 +67,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MSBuild.Bootstrap", "src\MS EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Build.UnGAC", "src\Package\Microsoft.Build.UnGAC\Microsoft.Build.UnGAC.csproj", "{B60173F0-F9F0-4688-9DF8-9ADDD57BD45F}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectCachePlugin", "src\Samples\ProjectCachePlugin\ProjectCachePlugin.csproj", "{F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -866,6 +868,36 @@ Global {B60173F0-F9F0-4688-9DF8-9ADDD57BD45F}.Release-MONO|x64.Build.0 = Release-MONO|x64 {B60173F0-F9F0-4688-9DF8-9ADDD57BD45F}.Release-MONO|x86.ActiveCfg = Release-MONO|Any CPU {B60173F0-F9F0-4688-9DF8-9ADDD57BD45F}.Release-MONO|x86.Build.0 = Release-MONO|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug|x64.ActiveCfg = Debug|x64 + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug|x64.Build.0 = Debug|x64 + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug|x86.ActiveCfg = Debug|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug|x86.Build.0 = Debug|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug-MONO|Any CPU.ActiveCfg = Debug-MONO|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug-MONO|Any CPU.Build.0 = Debug-MONO|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug-MONO|x64.ActiveCfg = Debug-MONO|x64 + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug-MONO|x64.Build.0 = Debug-MONO|x64 + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug-MONO|x86.ActiveCfg = Debug-MONO|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Debug-MONO|x86.Build.0 = Debug-MONO|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.MachineIndependent|Any CPU.ActiveCfg = MachineIndependent|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.MachineIndependent|Any CPU.Build.0 = MachineIndependent|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.MachineIndependent|x64.ActiveCfg = MachineIndependent|x64 + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.MachineIndependent|x64.Build.0 = MachineIndependent|x64 + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.MachineIndependent|x86.ActiveCfg = MachineIndependent|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.MachineIndependent|x86.Build.0 = MachineIndependent|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release|Any CPU.Build.0 = Release|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release|x64.ActiveCfg = Release|x64 + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release|x64.Build.0 = Release|x64 + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release|x86.ActiveCfg = Release|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release|x86.Build.0 = Release|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release-MONO|Any CPU.ActiveCfg = Release-MONO|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release-MONO|Any CPU.Build.0 = Release-MONO|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release-MONO|x64.ActiveCfg = Release-MONO|x64 + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release-MONO|x64.Build.0 = Release-MONO|x64 + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release-MONO|x86.ActiveCfg = Release-MONO|Any CPU + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943}.Release-MONO|x86.Build.0 = Release-MONO|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -883,6 +915,7 @@ Global {EDBFE32E-F264-4F01-97C3-B58F8B9165C9} = {9BAD9352-DEFB-45E5-B8A4-4816B9B22A33} {3D67E4FF-6EC6-4FE7-82F1-0DACE1E399A7} = {9BAD9352-DEFB-45E5-B8A4-4816B9B22A33} {B60173F0-F9F0-4688-9DF8-9ADDD57BD45F} = {9BAD9352-DEFB-45E5-B8A4-4816B9B22A33} + {F47E1A0A-7D81-40CF-B8B3-A0F4B5ADE943} = {760FF85D-8BEB-4992-8095-A9678F88FD47} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F948D667-14E3-4F98-BA50-3F3C948BF4C2} diff --git a/src/Build.UnitTests/Graph/IsolateProjects_Tests.cs b/src/Build.UnitTests/Graph/IsolateProjects_Tests.cs index 7cc2060da0f..62cc2016a58 100644 --- a/src/Build.UnitTests/Graph/IsolateProjects_Tests.cs +++ b/src/Build.UnitTests/Graph/IsolateProjects_Tests.cs @@ -473,8 +473,9 @@ public void SkippedTargetsShouldNotTriggerCacheMissEnforcement() ".Cleanup()).Path; _buildParametersPrototype.IsolateProjects.ShouldBeTrue(); + var buildParameters = _buildParametersPrototype.Clone(); - using (var buildManagerSession = new Helpers.BuildManagerSession(_env, _buildParametersPrototype)) + using (var buildManagerSession = new Helpers.BuildManagerSession(_env, buildParameters)) { // seed caches with results from the reference buildManagerSession.BuildProjectFile(referenceFile).OverallResult.ShouldBe(BuildResultCode.Success); diff --git a/src/Build.UnitTests/Microsoft.Build.Engine.UnitTests.csproj b/src/Build.UnitTests/Microsoft.Build.Engine.UnitTests.csproj index fdeaf730a1b..9b45fb61b7e 100644 --- a/src/Build.UnitTests/Microsoft.Build.Engine.UnitTests.csproj +++ b/src/Build.UnitTests/Microsoft.Build.Engine.UnitTests.csproj @@ -40,6 +40,12 @@ + + TargetFramework=$(FullFrameworkTFM) + TargetFramework=$(FullFrameworkTFM) + TargetFramework=netcoreapp2.1 + + diff --git a/src/Build.UnitTests/ProjectCache/ProjectCacheTests.cs b/src/Build.UnitTests/ProjectCache/ProjectCacheTests.cs new file mode 100644 index 00000000000..c7baf563774 --- /dev/null +++ b/src/Build.UnitTests/ProjectCache/ProjectCacheTests.cs @@ -0,0 +1,882 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Build.Execution; +using Microsoft.Build.Experimental.ProjectCache; +using Microsoft.Build.Framework; +using Microsoft.Build.Graph; +using Microsoft.Build.Shared; +using Microsoft.Build.Unittest; +using Microsoft.Build.UnitTests; +using Microsoft.Build.Utilities; +using Shouldly; +using Xunit; +using Xunit.Abstractions; +using Task = System.Threading.Tasks.Task; + +namespace Microsoft.Build.Engine.UnitTests.ProjectCache +{ + public class ProjectCacheTests : IDisposable + { + public ProjectCacheTests(ITestOutputHelper output) + { + _output = output; + _env = TestEnvironment.Create(output); + + BuildManager.ProjectCacheItems.ShouldBeEmpty(); + _env.WithInvariant(new CustomConditionInvariant(() => BuildManager.ProjectCacheItems.Count == 0)); + } + + public void Dispose() + { + _env.Dispose(); + } + + private static readonly Lazy SamplePluginAssemblyPath = + new Lazy( + () => + { + return Directory.EnumerateFiles( + Path.GetFullPath( + Path.Combine( + BuildEnvironmentHelper.Instance.CurrentMSBuildToolsDirectory, + "..", + "..", + "..", + "Samples", + "ProjectCachePlugin")), + "ProjectCachePlugin.dll", + SearchOption.AllDirectories).First(); + }); + + public class GraphCacheResponse + { + public const string CacheHitByProxy = nameof(CacheHitByProxy); + public const string CacheHitByTargetResult = nameof(CacheHitByTargetResult); + + private static readonly string P2PTargets = + @$" + + + <{ItemTypeNames.ProjectCachePlugin} Include=`{SamplePluginAssemblyPath.Value}` /> + + + + + + + + + Count()) != 0 and !Exists(%(ReferenceReturns.File))` /> + + + + + + + + + + + "; + + private Dictionary GraphEdges { get; } + + public Dictionary NonCacheMissResults { get; } + + public GraphCacheResponse(Dictionary graphEdges, Dictionary? nonCacheMissResults = null) + { + GraphEdges = graphEdges; + NonCacheMissResults = nonCacheMissResults ?? new Dictionary(); + } + + public ProjectGraph CreateGraph(TestEnvironment env) + { + return Helpers.CreateProjectGraph( + env, + GraphEdges, + null, + P2PTargets); + } + + public static CacheResult SuccessfulProxyTargetResult() + { + return CacheResult.IndicateCacheHit( + new ProxyTargets( + new Dictionary + { + {"ProxyBuild", "Build"} + })); + } + + public static CacheResult SuccessfulTargetResult(int projectNumber, string projectPath) + { + return CacheResult.IndicateCacheHit( + new[] + { + new PluginTargetResult( + "Build", + new ITaskItem2[] + { + new TaskItem( + projectNumber.ToString(), + new Dictionary + { + {"File", projectPath}, + {CacheHitByTargetResult, "true"} + }) + }, + BuildResultCode.Success + ) + }); + } + + public CacheResult GetExpectedCacheResultForNode(ProjectGraphNode node) + { + return GetExpectedCacheResultForProjectNumber(GetProjectNumber(node)); + } + + public CacheResult GetExpectedCacheResultForProjectNumber(int projectNumber) + { + return NonCacheMissResults.TryGetValue(projectNumber, out var cacheResult) + ? cacheResult + : CacheResult.IndicateNonCacheHit(CacheResultType.CacheMiss); + } + + public override string ToString() + { + //return base.ToString(); + return string.Join( + ", ", + GraphEdges.Select(e => $"{Node(e.Key)}->{FormatChildren(e.Value)}")); + + string FormatChildren(int[] children) + { + return children == null + ? "Null" + : string.Join(",", children.Select(c => Node(c))); + } + + string Node(int projectNumber) + { + return $"{projectNumber}({Chr(projectNumber)})"; + } + + char Chr(int projectNumber) + { + var cacheResult = GetExpectedCacheResultForProjectNumber(projectNumber); + return cacheResult.ResultType switch + { + + CacheResultType.CacheHit => cacheResult.ProxyTargets != null + ? 'P' + : 'T', + CacheResultType.CacheMiss => 'M', + CacheResultType.CacheNotApplicable => 'N', + CacheResultType.CacheError => 'E', + _ => throw new ArgumentOutOfRangeException() + }; + } + } + } + + [Flags] + public enum ExceptionLocations + { + Constructor = 1 << 0, + BeginBuildAsync = 1 << 1, + GetCacheResultAsync = 1 << 2, + EndBuildAsync = 1 << 3 + } + + public class MockProjectCache : ProjectCacheBase + { + private readonly GraphCacheResponse? _testData; + public ConcurrentQueue Requests { get; } = new ConcurrentQueue(); + + public bool BeginBuildCalled { get; set; } + public bool EndBuildCalled { get; set; } + + public MockProjectCache(GraphCacheResponse? testData = null) + { + _testData = testData; + } + + public override Task BeginBuildAsync(CacheContext context, PluginLoggerBase logger, CancellationToken cancellationToken) + { + logger.LogMessage("MockCache: BeginBuildAsync", MessageImportance.High); + + BeginBuildCalled = true; + + return Task.CompletedTask; + } + + public override Task GetCacheResultAsync( + BuildRequestData buildRequest, + PluginLoggerBase logger, + CancellationToken cancellationToken) + { + Requests.Enqueue(buildRequest); + logger.LogMessage($"MockCache: GetCacheResultAsync for {buildRequest.ProjectFullPath}", MessageImportance.High); + + return + Task.FromResult( + _testData?.GetExpectedCacheResultForProjectNumber(GetProjectNumber(buildRequest.ProjectFullPath)) + ?? CacheResult.IndicateNonCacheHit(CacheResultType.CacheMiss)); + } + + public override Task EndBuildAsync(PluginLoggerBase logger, CancellationToken cancellationToken) + { + logger.LogMessage("MockCache: EndBuildAsync", MessageImportance.High); + + EndBuildCalled = true; + + return Task.CompletedTask; + } + + public CacheResult GetCacheResultForNode(ProjectGraphNode node) + { + throw new NotImplementedException(); + } + } + + private readonly TestEnvironment _env; + + private readonly ITestOutputHelper _output; + + public static IEnumerable SuccessfulGraphs + { + get + { + yield return new GraphCacheResponse( + new Dictionary + { + {1, null!} + }); + + yield return new GraphCacheResponse( + new Dictionary + { + {1, null!} + }, + new Dictionary + { + {1, GraphCacheResponse.SuccessfulProxyTargetResult()} + }); + + yield return new GraphCacheResponse( + new Dictionary + { + {1, null!} + }, + new Dictionary + { + {1, GraphCacheResponse.SuccessfulTargetResult(1, "1.proj")} + }); + + yield return new GraphCacheResponse( + new Dictionary + { + {1, new[] {2}} + }); + + yield return new GraphCacheResponse( + new Dictionary + { + {1, new[] {2}} + }, + new Dictionary + { + {2, GraphCacheResponse.SuccessfulProxyTargetResult()} + }); + + yield return new GraphCacheResponse( + new Dictionary + { + {1, new[] {2}} + }, + new Dictionary + { + {2, GraphCacheResponse.SuccessfulTargetResult(2, "2.proj")} + }); + + yield return new GraphCacheResponse( + new Dictionary + { + {1, new[] {2}} + }, + new Dictionary + { + {1, GraphCacheResponse.SuccessfulProxyTargetResult()}, + {2, GraphCacheResponse.SuccessfulTargetResult(2, "2.proj")} + }); + + yield return new GraphCacheResponse( + new Dictionary + { + {1, new[] {2, 3, 7}}, + {2, new[] {4}}, + {3, new[] {4}}, + {4, new[] {5, 6, 7}} + }); + } + } + + public static IEnumerable MultiProcWithAndWithoutInProcNode + { + get + { + yield return new object[] + { + new BuildParameters + { + DisableInProcNode = false, + MaxNodeCount = Environment.ProcessorCount + } + }; + + yield return new object[] + { + new BuildParameters + { + DisableInProcNode = true, + MaxNodeCount = Environment.ProcessorCount + } + }; + } + } + + public static IEnumerable SuccessfulGraphsWithBuildParameters + { + get + { + foreach (var graph in SuccessfulGraphs) + { + foreach (var buildParameters in MultiProcWithAndWithoutInProcNode) + { + yield return new object[] + { + graph, + ((BuildParameters) buildParameters.First()).Clone() + }; + } + } + } + } + + [Theory] + [MemberData(nameof(SuccessfulGraphsWithBuildParameters))] + public void ProjectCacheByBuildParametersAndGraphBuildWorks(GraphCacheResponse testData, BuildParameters buildParameters) + { + _output.WriteLine(testData.ToString()); + var graph = testData.CreateGraph(_env); + var mockCache = new MockProjectCache(testData); + + buildParameters.ProjectCacheDescriptor = ProjectCacheDescriptor.FromInstance( + mockCache, + null, + graph); + + using var buildSession = new Helpers.BuildManagerSession(_env, buildParameters); + + var graphResult = buildSession.BuildGraph(graph); + + graphResult.OverallResult.ShouldBe(BuildResultCode.Success); + + buildSession.Dispose(); + + buildSession.Logger.FullLog.ShouldContain("Static graph based"); + + AssertCacheBuild(graph, testData, mockCache, buildSession.Logger, graphResult.ResultsByNode); + } + + [Theory] + [MemberData(nameof(SuccessfulGraphsWithBuildParameters))] + public void ProjectCacheByBuildParametersAndBottomUpBuildWorks(GraphCacheResponse testData, BuildParameters buildParameters) + { + var graph = testData.CreateGraph(_env); + var mockCache = new MockProjectCache(testData); + + buildParameters.ProjectCacheDescriptor = ProjectCacheDescriptor.FromInstance( + mockCache, + null, + graph); + + using var buildSession = new Helpers.BuildManagerSession(_env, buildParameters); + var nodesToBuildResults = new Dictionary(); + + foreach (var node in graph.ProjectNodesTopologicallySorted) + { + var buildResult = buildSession.BuildProjectFile(node.ProjectInstance.FullPath); + buildResult.OverallResult.ShouldBe(BuildResultCode.Success); + + nodesToBuildResults[node] = buildResult; + } + + buildSession.Dispose(); + + buildSession.Logger.FullLog.ShouldContain("Static graph based"); + + AssertCacheBuild(graph, testData, mockCache, buildSession.Logger, nodesToBuildResults); + } + + [Theory] + [MemberData(nameof(SuccessfulGraphsWithBuildParameters))] + public void ProjectCacheByVSWorkaroundWorks(GraphCacheResponse testData, BuildParameters buildParameters) + { + var currentBuildEnvironment = BuildEnvironmentHelper.Instance; + + try + { + BuildEnvironmentHelper.ResetInstance_ForUnitTestsOnly( + new BuildEnvironment( + currentBuildEnvironment.Mode, + currentBuildEnvironment.CurrentMSBuildExePath, + currentBuildEnvironment.RunningTests, + true, + currentBuildEnvironment.VisualStudioInstallRootDirectory)); + + BuildManager.ProjectCacheItems.ShouldBeEmpty(); + + var graph = testData.CreateGraph(_env); + + BuildManager.ProjectCacheItems.ShouldHaveSingleItem(); + + using var buildSession = new Helpers.BuildManagerSession(_env, buildParameters); + var nodesToBuildResults = new Dictionary(); + + foreach (var node in graph.ProjectNodesTopologicallySorted) + { + var buildResult = buildSession.BuildProjectFile( + node.ProjectInstance.FullPath, + globalProperties: + new Dictionary {{"SolutionPath", graph.GraphRoots.First().ProjectInstance.FullPath}}); + buildResult.OverallResult.ShouldBe(BuildResultCode.Success); + + nodesToBuildResults[node] = buildResult; + } + + buildSession.Logger.FullLog.ShouldContain("Graph entrypoint based"); + + AssertCacheBuild(graph, testData, null, buildSession.Logger, nodesToBuildResults); + } + finally + { + BuildEnvironmentHelper.ResetInstance_ForUnitTestsOnly(currentBuildEnvironment); + BuildManager.ProjectCacheItems.Clear(); + } + } + + private void AssertCacheBuild( + ProjectGraph graph, + GraphCacheResponse testData, + MockProjectCache? mockCache, + MockLogger mockLogger, + IReadOnlyDictionary projectPathToBuildResults) + { + if (mockCache != null) + { + mockLogger.FullLog.ShouldContain("MockCache: BeginBuildAsync"); + mockLogger.FullLog.ShouldContain("Instance based"); + mockLogger.FullLog.ShouldNotContain("Assembly path based"); + + mockCache.Requests.Count.ShouldBe(graph.ProjectNodes.Count); + } + else + { + mockLogger.FullLog.ShouldContain("MockCacheFromAssembly: BeginBuildAsync"); + mockLogger.FullLog.ShouldContain("Assembly path based"); + mockLogger.FullLog.ShouldNotContain("Instance based"); + + Regex.Matches(mockLogger.FullLog, "MockCacheFromAssembly: GetCacheResultAsync for").Count.ShouldBe(graph.ProjectNodes.Count); + } + + foreach (var node in graph.ProjectNodes) + { + var expectedCacheResponse = testData.GetExpectedCacheResultForNode(node); + + mockLogger.FullLog.ShouldContain($"====== Querying project cache for project {node.ProjectInstance.FullPath}"); + + if (mockCache != null) + { + mockCache.Requests.ShouldContain(r => r.ProjectFullPath.Equals(node.ProjectInstance.FullPath)); + mockCache.BeginBuildCalled.ShouldBeTrue(); + mockCache.EndBuildCalled.ShouldBeTrue(); + } + else + { + mockLogger.FullLog.ShouldContain($"MockCacheFromAssembly: GetCacheResultAsync for {node.ProjectInstance.FullPath}"); + } + + if (mockCache == null) + { + // Too complicated, not worth it to send expected results to the assembly plugin, so skip checking the build results. + continue; + } + + switch (expectedCacheResponse.ResultType) + { + case CacheResultType.CacheHit: + AssertBuildResultForCacheHit(node.ProjectInstance.FullPath, projectPathToBuildResults[node], expectedCacheResponse); + break; + case CacheResultType.CacheMiss: + break; + case CacheResultType.CacheNotApplicable: + break; + case CacheResultType.CacheError: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + private static int GetProjectNumber(ProjectGraphNode node) + { + return GetProjectNumber(node.ProjectInstance.FullPath); + } + + private static int GetProjectNumber(string projectPath) + { + return int.Parse(Path.GetFileNameWithoutExtension(projectPath)); + } + + private void AssertBuildResultForCacheHit( + string projectPath, + BuildResult buildResult, + CacheResult expectedCacheResponse) + { + // If the cache hit is via proxy targets then the build result should contain entry for both the real target + // and the proxy target. Both target results should be the same. + // If it's not a cache result by proxy targets then the cache constructed the target results by hand and only the real target result + // exists in the BuildResult. + + var targetResult = buildResult.ResultsByTarget["Build"]; + + targetResult.Items.ShouldHaveSingleItem(); + var itemResult = targetResult.Items.First(); + string expectedMetadata; + + if (expectedCacheResponse.ProxyTargets != null) + { + var proxyTargetResult = buildResult.ResultsByTarget["ProxyBuild"]; + SdkUtilities.EngineHelpers.AssertTargetResultsEqual(targetResult, proxyTargetResult); + + expectedMetadata = GraphCacheResponse.CacheHitByProxy; + } + else + { + expectedMetadata = GraphCacheResponse.CacheHitByTargetResult; + } + + itemResult.ItemSpec.ShouldBe(GetProjectNumber(projectPath).ToString()); + itemResult.GetMetadata("File").ShouldBe(Path.GetFileName(projectPath)); + itemResult.GetMetadata(expectedMetadata).ShouldBe("true"); + } + + [Theory] + [MemberData(nameof(MultiProcWithAndWithoutInProcNode))] + public void CacheShouldNotGetQueriedForNestedBuildRequests(BuildParameters buildParameters) + { + var project1 = _env.CreateFile("1.proj", @" + + + + + ".Cleanup()); + + _env.CreateFile("2.proj", @" + + + + + ".Cleanup()); + + var mockCache = new MockProjectCache(); + buildParameters.ProjectCacheDescriptor = ProjectCacheDescriptor.FromInstance( + mockCache, + new[] {new ProjectGraphEntryPoint(project1.Path)}, + null); + + using var buildSession = new Helpers.BuildManagerSession(_env, buildParameters); + + var buildResult = buildSession.BuildProjectFile(project1.Path); + + buildResult.OverallResult.ShouldBe(BuildResultCode.Success); + + buildSession.Logger.ProjectStartedEvents.Count.ShouldBe(2); + + mockCache.Requests.Count.ShouldBe(1); + mockCache.Requests.First().ProjectFullPath.ShouldEndWith("1.proj"); + } + + [Fact] + public void CacheViaBuildParametersCanDiscoverAndLoadPluginFromAssembly() + { + var testData = new GraphCacheResponse( + new Dictionary + { + {1, new[] {2, 3}} + } + ); + + var graph = testData.CreateGraph(_env); + + using var buildSession = new Helpers.BuildManagerSession( + _env, + new BuildParameters + { + ProjectCacheDescriptor = ProjectCacheDescriptor.FromAssemblyPath( + SamplePluginAssemblyPath.Value, + graph.EntryPointNodes.Select(n => new ProjectGraphEntryPoint(n.ProjectInstance.FullPath)).ToArray(), + null) + }); + + var graphResult = buildSession.BuildGraph(graph); + + graphResult.OverallResult.ShouldBe(BuildResultCode.Success); + + buildSession.Logger.FullLog.ShouldContain("Graph entrypoint based"); + + AssertCacheBuild(graph, testData, null, buildSession.Logger, graphResult.ResultsByNode); + } + + [Fact] + public void GraphBuildCanDiscoverAndLoadPluginFromAssembly() + { + var testData = new GraphCacheResponse( + new Dictionary + { + {1, new[] {2, 3}} + } + ); + + var graph = testData.CreateGraph(_env); + + using var buildSession = new Helpers.BuildManagerSession(_env); + + var graphResult = buildSession.BuildGraph(graph); + + graphResult.OverallResult.ShouldBe(BuildResultCode.Success); + + buildSession.Logger.FullLog.ShouldContain("Static graph based"); + + AssertCacheBuild(graph, testData, null, buildSession.Logger, graphResult.ResultsByNode); + } + + [Fact] + public void BuildFailsWhenCacheBuildResultIsWrong() + { + var testData = new GraphCacheResponse( + new Dictionary + { + {1, new[] {2}} + }, + new Dictionary + { + { + 2, CacheResult.IndicateCacheHit( + new[] + { + new PluginTargetResult( + "Build", + new ITaskItem2[] + { + new TaskItem( + "NA", + new Dictionary + { + {"File", "Invalid file"} + }) + }, + BuildResultCode.Success + ) + }) + } + } + ); + + var graph = testData.CreateGraph(_env); + var mockCache = new MockProjectCache(testData); + + using var buildSession = new Helpers.BuildManagerSession( + _env, + new BuildParameters + { + ProjectCacheDescriptor = + ProjectCacheDescriptor.FromInstance(mockCache, null, graph) + }); + + var buildResult = buildSession.BuildGraph(graph); + + mockCache.Requests.Count.ShouldBe(2); + + buildResult.ResultsByNode.First(r => GetProjectNumber(r.Key) == 2).Value.OverallResult.ShouldBe(BuildResultCode.Success); + buildResult.ResultsByNode.First(r => GetProjectNumber(r.Key) == 1).Value.OverallResult.ShouldBe(BuildResultCode.Failure); + + buildResult.OverallResult.ShouldBe(BuildResultCode.Failure); + + buildSession.Logger.FullLog.ShouldContain("Reference file [Invalid file] does not exist"); + } + + [Fact] + public void GraphBuildErrorsIfMultiplePluginsAreFound() + { + _env.DoNotLaunchDebugger(); + + var graph = Helpers.CreateProjectGraph( + _env, + new Dictionary + { + {1, new[] {2}} + }, + extraContentPerProjectNumber: null, + extraContentForAllNodes: @$" + + <{ItemTypeNames.ProjectCachePlugin} Include='Plugin$(MSBuildProjectName)' /> + +"); + + using var buildSession = new Helpers.BuildManagerSession(_env); + + var graphResult = buildSession.BuildGraph(graph); + + graphResult.OverallResult.ShouldBe(BuildResultCode.Failure); + graphResult.Exception.Message.ShouldContain("A single project cache plugin must be specified but multiple where found:"); + } + + [Fact] + public void GraphBuildErrorsIfNotAllNodeDefineAPlugin() + { + _env.DoNotLaunchDebugger(); + + var graph = Helpers.CreateProjectGraph( + _env, + dependencyEdges: new Dictionary + { + {1, new[] {2}} + }, + extraContentPerProjectNumber: new Dictionary + { + { + 2, + @$" + + <{ItemTypeNames.ProjectCachePlugin} Include='Plugin$(MSBuildProjectName)' /> + +" + } + }); + + using var buildSession = new Helpers.BuildManagerSession(_env); + + var graphResult = buildSession.BuildGraph(graph); + + graphResult.OverallResult.ShouldBe(BuildResultCode.Failure); + graphResult.Exception.Message.ShouldContain("When any static graph node defines a project cache, all nodes must define the same project cache."); + } + + public static IEnumerable CacheExceptionLocationsTestData + { + get + { + yield return new object[]{ExceptionLocations.Constructor}; + + yield return new object[]{ExceptionLocations.BeginBuildAsync}; + yield return new object[]{ExceptionLocations.BeginBuildAsync | ExceptionLocations.GetCacheResultAsync}; + yield return new object[]{ExceptionLocations.BeginBuildAsync | ExceptionLocations.GetCacheResultAsync | ExceptionLocations.EndBuildAsync}; + yield return new object[]{ExceptionLocations.BeginBuildAsync | ExceptionLocations.EndBuildAsync}; + + yield return new object[]{ExceptionLocations.GetCacheResultAsync}; + yield return new object[]{ExceptionLocations.GetCacheResultAsync | ExceptionLocations.EndBuildAsync}; + + yield return new object[]{ExceptionLocations.EndBuildAsync}; + } + } + + [Theory] + [MemberData(nameof(CacheExceptionLocationsTestData))] + public void EngineShouldHandleExceptionsFromCachePlugin(ExceptionLocations exceptionLocations) + { + _env.DoNotLaunchDebugger(); + + var project = _env.CreateFile("1.proj", @$" + + + + + ".Cleanup()); + + foreach (var enumValue in Enum.GetValues(typeof(ExceptionLocations))) + { + var typedValue = (ExceptionLocations) enumValue; + if (exceptionLocations.HasFlag(typedValue)) + { + var exceptionLocation = typedValue.ToString(); + _env.SetEnvironmentVariable(exceptionLocation, "1"); + _output.WriteLine($"Set exception location: {exceptionLocation}"); + } + } + + using var buildSession = new Helpers.BuildManagerSession( + _env, + new BuildParameters + { + UseSynchronousLogging = true, + ProjectCacheDescriptor = ProjectCacheDescriptor.FromAssemblyPath( + SamplePluginAssemblyPath.Value, + new[] {new ProjectGraphEntryPoint(project.Path)}, + null) + }); + + var logger = buildSession.Logger; + var buildResult = buildSession.BuildProjectFile(project.Path); + + if (exceptionLocations == ExceptionLocations.EndBuildAsync || exceptionLocations == (ExceptionLocations.GetCacheResultAsync + | ExceptionLocations.EndBuildAsync)) + { + var e = Should.Throw(() => buildSession.Dispose()); + e.Message.ShouldContain("Cache plugin exception from EndBuildAsync"); + } + else + { + buildSession.Dispose(); + } + + var exceptionsThatEndUpInBuildResult = ExceptionLocations.Constructor | ExceptionLocations.BeginBuildAsync | ExceptionLocations.GetCacheResultAsync; + + if ((exceptionsThatEndUpInBuildResult & exceptionLocations) != 0) + { + buildResult.OverallResult.ShouldBe(BuildResultCode.Failure); + buildResult.Exception.Message.ShouldContain("Cache plugin exception from"); + } + + if (exceptionLocations == ExceptionLocations.EndBuildAsync) + { + buildResult.OverallResult.ShouldBe(BuildResultCode.Success); + } + + var exceptionsThatShouldPreventCacheQueryAndEndBuildAsync = ExceptionLocations.Constructor | ExceptionLocations.BeginBuildAsync; + + if ((exceptionsThatShouldPreventCacheQueryAndEndBuildAsync & exceptionLocations) != 0) + { + logger.FullLog.ShouldNotContain("MockCacheFromAssembly: GetCacheResultAsync for"); + logger.FullLog.ShouldNotContain("MockCacheFromAssembly: EndBuildAsync"); + } + else + { + logger.FullLog.ShouldContain("MockCacheFromAssembly: GetCacheResultAsync for"); + logger.FullLog.ShouldContain("MockCacheFromAssembly: EndBuildAsync"); + } + } + } +} diff --git a/src/Build/BackEnd/BuildManager/BuildManager.cs b/src/Build/BackEnd/BuildManager/BuildManager.cs index b4e55da6d6a..590c5b04be3 100644 --- a/src/Build/BackEnd/BuildManager/BuildManager.cs +++ b/src/Build/BackEnd/BuildManager/BuildManager.cs @@ -1136,6 +1136,7 @@ void ExecuteSubmissionImpl() result.AddResultsForTarget(targetResult.Key, targetResult.Value); } + _resultsCache.AddResult(result); submission.CompleteLogging(false); ReportResultsToSubmission(result); } @@ -1272,7 +1273,10 @@ private void InstantiateProjectCacheServiceForVisualStudioWorkaround( _buildParameters.ProjectCacheDescriptor == null) { _projectCacheServiceInstantiatedByVSWorkaround = true; - ErrorUtilities.VerifyThrowInvalidOperation(ProjectCacheItems.Count == 1, "OnlyOneCachePluginMustBeSpecified"); + ErrorUtilities.VerifyThrowInvalidOperation( + ProjectCacheItems.Count == 1, + "OnlyOneCachePluginMustBeSpecified", + string.Join("; ", ProjectCacheItems.Values.Select(c => c.PluginPath))); LoadSubmissionProjectIntoConfiguration(submission, config); @@ -1602,6 +1606,10 @@ private void HandleExecuteSubmissionException(GraphBuildSubmission submission, E } } + ex = ex is AggregateException ae && ae.InnerExceptions.Count == 1 + ? ae.InnerExceptions.First() + : ex; + if (submission.IsStarted) { submission.CompleteResults(new GraphBuildResult(submission.SubmissionId, ex)); @@ -1882,9 +1890,8 @@ private DisposePluginService SearchAndInitializeProjectCachePluginFromGraph(Proj ErrorUtilities.VerifyThrowInvalidOperation( cacheItems.Count == 1, - ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword( - "OnlyOneCachePluginMustBeSpecified", - string.Join("; ", cacheItems.Select(ci => ci.PluginPath)))); + "OnlyOneCachePluginMustBeSpecified", + string.Join("; ", cacheItems.Select(ci => ci.PluginPath))); var nodesWithoutCacheItems = nodeToCacheItems.Where(kvp => kvp.Value.Length == 0).ToArray(); diff --git a/src/Build/BackEnd/Components/ProjectCache/ProjectCacheDescriptor.cs b/src/Build/BackEnd/Components/ProjectCache/ProjectCacheDescriptor.cs index 3681b2758ba..a9481d72261 100644 --- a/src/Build/BackEnd/Components/ProjectCache/ProjectCacheDescriptor.cs +++ b/src/Build/BackEnd/Components/ProjectCache/ProjectCacheDescriptor.cs @@ -88,7 +88,7 @@ public override string ToString() : $"Assembly path based: {PluginAssemblyPath}"; var entryPointStyle = EntryPoints != null - ? "Non static graph based" + ? "Graph entrypoint based" : "Static graph based"; var entryPoints = EntryPoints != null @@ -103,9 +103,11 @@ public override string ToString() return $"{loadStyle}\nEntry-point style: {entryPointStyle}\nEntry-points:\n{entryPoints}"; - static string FormatGlobalProperties(IDictionary globalProperties) + static string FormatGlobalProperties(IDictionary? globalProperties) { - return string.Join(", ", globalProperties.Select(gp => $"{gp.Key}={gp.Value}")); + return globalProperties == null + ? string.Empty + : string.Join(", ", globalProperties.Select(gp => $"{gp.Key}={gp.Value}")); } } } diff --git a/src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs b/src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs index ebcd8ce4041..8b0ea9accf2 100644 --- a/src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs +++ b/src/Build/BackEnd/Components/ProjectCache/ProjectCacheService.cs @@ -144,7 +144,7 @@ public async Task GetCacheResultAsync(BuildRequestData buildRequest $"\n\tGlobal Properties: {{{string.Join(",", buildRequest.GlobalProperties.Select(kvp => $"{kvp.Name}={kvp.EvaluatedValue}"))}}}"; _logger.LogMessage( - "\n====== Querying plugin for project " + queryDescription, + "\n====== Querying project cache for project " + queryDescription, MessageImportance.High); var cacheResult = await _projectCachePlugin.GetCacheResultAsync(buildRequest, _logger, _cancellationToken); diff --git a/src/Samples/ProjectCachePlugin/MockCacheFromAssembly.cs b/src/Samples/ProjectCachePlugin/MockCacheFromAssembly.cs new file mode 100644 index 00000000000..d9632a73a7b --- /dev/null +++ b/src/Samples/ProjectCachePlugin/MockCacheFromAssembly.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Build.Execution; +using Microsoft.Build.Experimental.ProjectCache; +using Microsoft.Build.Framework; + +namespace MockCacheFromAssembly +{ + public class MockCacheFromAssembly : ProjectCacheBase + { + public MockCacheFromAssembly() + { + ThrowFrom("Constructor"); + } + + public override Task BeginBuildAsync(CacheContext context, PluginLoggerBase logger, CancellationToken cancellationToken) + { + logger.LogMessage("MockCacheFromAssembly: BeginBuildAsync", MessageImportance.High); + + ThrowFrom(nameof(BeginBuildAsync)); + + return Task.CompletedTask; + } + + public override Task GetCacheResultAsync( + BuildRequestData buildRequest, + PluginLoggerBase logger, + CancellationToken cancellationToken) + { + logger.LogMessage($"MockCacheFromAssembly: GetCacheResultAsync for {buildRequest.ProjectFullPath}", MessageImportance.High); + + ThrowFrom(nameof(GetCacheResultAsync)); + + return Task.FromResult(CacheResult.IndicateNonCacheHit(CacheResultType.CacheNotApplicable)); + } + + public override Task EndBuildAsync(PluginLoggerBase logger, CancellationToken cancellationToken) + { + logger.LogMessage("MockCacheFromAssembly: EndBuildAsync", MessageImportance.High); + + ThrowFrom(nameof(EndBuildAsync)); + + return Task.CompletedTask; + } + + private static void ThrowFrom(string throwFrom) + { + if (Environment.GetEnvironmentVariable(throwFrom) != null) + { + throw new Exception($"Cache plugin exception from {throwFrom}"); + } + } + } +} diff --git a/src/Samples/ProjectCachePlugin/ProjectCachePlugin.csproj b/src/Samples/ProjectCachePlugin/ProjectCachePlugin.csproj new file mode 100644 index 00000000000..9e1faa4fdbf --- /dev/null +++ b/src/Samples/ProjectCachePlugin/ProjectCachePlugin.csproj @@ -0,0 +1,15 @@ + + + true + false + false + + netcoreapp2.1 + $(FullFrameworkTFM);netcoreapp2.1 + $(RuntimeOutputTargetFrameworks) + + + + + + diff --git a/src/Shared/BuildEnvironmentHelper.cs b/src/Shared/BuildEnvironmentHelper.cs index 9d211bc1148..3785175e7fd 100644 --- a/src/Shared/BuildEnvironmentHelper.cs +++ b/src/Shared/BuildEnvironmentHelper.cs @@ -440,6 +440,14 @@ internal static void ResetInstance_ForUnitTestsOnly(Func getProcessFromR BuildEnvironmentHelperSingleton.s_instance = Initialize(); } + /// + /// Resets the current singleton instance (for testing). + /// + internal static void ResetInstance_ForUnitTestsOnly(BuildEnvironment buildEnvironment) + { + BuildEnvironmentHelperSingleton.s_instance = buildEnvironment; + } + private static Func s_getProcessFromRunningProcess = GetProcessFromRunningProcess; private static Func s_getExecutingAssemblyPath = GetExecutingAssemblyPath; private static Func s_getAppContextBaseDirectory = GetAppContextBaseDirectory; diff --git a/src/Shared/UnitTests/MockLogger.cs b/src/Shared/UnitTests/MockLogger.cs index 07af5356dc7..a2307e41713 100644 --- a/src/Shared/UnitTests/MockLogger.cs +++ b/src/Shared/UnitTests/MockLogger.cs @@ -250,7 +250,7 @@ internal void LoggerEventHandler(object sender, BuildEventArgs eventArgs) { string logMessage = $"{w.File}({w.LineNumber},{w.ColumnNumber}): {w.Subcategory} warning {w.Code}: {w.Message}"; - _fullLog.AppendLine(logMessage); + WriteLineToFullLog(logMessage); _testOutputHelper?.WriteLine(logMessage); ++WarningCount; @@ -260,7 +260,7 @@ internal void LoggerEventHandler(object sender, BuildEventArgs eventArgs) case BuildErrorEventArgs e: { string logMessage = $"{e.File}({e.LineNumber},{e.ColumnNumber}): {e.Subcategory} error {e.Code}: {e.Message}"; - _fullLog.AppendLine(logMessage); + WriteLineToFullLog(logMessage); _testOutputHelper?.WriteLine(logMessage); ++ErrorCount; @@ -273,7 +273,7 @@ internal void LoggerEventHandler(object sender, BuildEventArgs eventArgs) bool logMessage = !(eventArgs is BuildFinishedEventArgs) || LogBuildFinished; if (logMessage) { - _fullLog.AppendLine(eventArgs.Message); + WriteLineToFullLog(eventArgs.Message); _testOutputHelper?.WriteLine(eventArgs.Message); } break; @@ -359,6 +359,11 @@ internal void LoggerEventHandler(object sender, BuildEventArgs eventArgs) } } + private void WriteLineToFullLog(string line) + { + _fullLog.AppendLine(line); + } + private void PrintFullLog() { if (_printEventsToStdout) diff --git a/src/Shared/UnitTests/ObjectModelHelpers.cs b/src/Shared/UnitTests/ObjectModelHelpers.cs index 6c62d8044f4..e97c2d83329 100644 --- a/src/Shared/UnitTests/ObjectModelHelpers.cs +++ b/src/Shared/UnitTests/ObjectModelHelpers.cs @@ -1896,14 +1896,14 @@ internal class BuildManagerSession : IDisposable { private readonly TestEnvironment _env; private readonly BuildManager _buildManager; + private bool _disposed; public MockLogger Logger { get; set; } public BuildManagerSession( TestEnvironment env, - BuildParameters buildParametersPrototype = null, + BuildParameters buildParameters = null, bool enableNodeReuse = false, - bool shutdownInProcNode = true, IEnumerable deferredMessages = null) { _env = env; @@ -1911,24 +1911,27 @@ public BuildManagerSession( Logger = new MockLogger(_env.Output); var loggers = new[] {Logger}; - var actualBuildParameters = buildParametersPrototype?.Clone() ?? new BuildParameters(); + var actualBuildParameters = buildParameters ?? new BuildParameters(); actualBuildParameters.Loggers = actualBuildParameters.Loggers == null ? loggers : actualBuildParameters.Loggers.Concat(loggers).ToArray(); - actualBuildParameters.ShutdownInProcNodeOnBuildFinish = shutdownInProcNode; + actualBuildParameters.ShutdownInProcNodeOnBuildFinish = true; actualBuildParameters.EnableNodeReuse = enableNodeReuse; _buildManager = new BuildManager(); _buildManager.BeginBuild(actualBuildParameters, deferredMessages); } - public BuildResult BuildProjectFile(string projectFile, string[] entryTargets = null) + public BuildResult BuildProjectFile( + string projectFile, + string[] entryTargets = null, + Dictionary globalProperties = null) { var buildResult = _buildManager.BuildRequest( new BuildRequestData(projectFile, - new Dictionary(), + globalProperties ?? new Dictionary(), MSBuildConstants.CurrentToolsVersion, entryTargets ?? new string[0], null)); @@ -1938,9 +1941,21 @@ public BuildResult BuildProjectFile(string projectFile, string[] entryTargets = public void Dispose() { + if (_disposed) + { + return; + } + + _disposed = true; + _buildManager.EndBuild(); _buildManager.Dispose(); } + + public GraphBuildResult BuildGraph(ProjectGraph graph, string[] entryTargets = null) + { + return _buildManager.BuildRequest(new GraphBuildRequestData(graph, entryTargets ?? new string[0])); + } } internal class LoggingFileSystem : MSBuildFileSystemBase diff --git a/src/Shared/UnitTests/TestEnvironment.cs b/src/Shared/UnitTests/TestEnvironment.cs index 8f05e3b94b5..c147d3344b3 100644 --- a/src/Shared/UnitTests/TestEnvironment.cs +++ b/src/Shared/UnitTests/TestEnvironment.cs @@ -474,6 +474,21 @@ public override void AssertInvariant(ITestOutputHelper output) } } + public class CustomConditionInvariant : TestInvariant + { + private readonly Func _condition; + + public CustomConditionInvariant(Func condition) + { + _condition = condition; + } + + public override void AssertInvariant(ITestOutputHelper output) + { + _condition().ShouldBeTrue(); + } + } + public class TransientTempPath : TransientTestState { private const string TMP = "TMP";