Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Load plugins in an AssemblyLoadContext #4916

Merged
merged 7 commits into from
Jan 9, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions documentation/specs/task-isolation-and-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
Tasks in MSBuild are dynamically loaded assemblies with potentially separate and colliding dependency trees. Currently MSBuild on .NET Core has no isolation between tasks and as such only one version of any given assembly can be loaded. Prime example of this is Newtonsoft.Json which has multiple versions, but all the tasks must agree on it to work.
This problem is also described in #1754.

## Possible solution
## Solution
Use [`AssemblyLoadContext`](https://docs.microsoft.com/en-us/dotnet/api/system.runtime.loader.assemblyloadcontext?view=netcore-2.2) (ALC) to provide binding isolation for task assemblies. Each task assembly would be loaded into its own ALC instance.
* The ALC would resolve all dependencies of the task assemblies (see dependency resolution below)
* ALC would fallback to the Default for dependencies which the assembly doesn't carry with itself (frameworks and so on)
* ALC would probably have to forcefully fallback for MSBuild assemblies since it's possible that tasks will carry these, but the system requires for the MSBuild assemblies to be shared.
We would probably also want to load groups of tasks which belong together into the same ALC (for example based on their location on disk) to improve performance. This will need some care as there's no guarantee that two random tasks have compatible dependency trees.

We also want to load groups of tasks which belong together into the same ALC (for example based on their location on disk) to improve performance. This will need some care as there's no guarantee that two random tasks have compatible dependency trees. As implemented, each task assembly is loaded into its own ALC.

## Potential risks
* Has some small probability of causing breaks. Currently all assemblies from all tasks are loaded into the default context and thus are "visible" to everybody. Tasks with following properties might not work:
Expand All @@ -20,13 +21,11 @@ We would probably also want to load groups of tasks which belong together into t
* None of these changes would have any effect on MSBuild on .NET Framework
* Task isolation alone could be achieved on existing MSBuild



# Task dependency resolution
## Problem definition
Tasks with complex and specifically platform specific dependencies don't work out of the box. For example if a task uses [`LibGit2Sharp`](https://www.nuget.org/packages/LibGit2Sharp) package it will not work as is. `LibGit2Sharp` has native dependencies which are platform specific. While the package carries all of them, there's no built in support for the task to load the right ones. For example [source link](https://github.com/dotnet/sourcelink/blob/master/src/Microsoft.Build.Tasks.Git/GitLoaderContext.cs) runs into this problem.

## Possible solution
## Solution
.NET Core uses `.deps.json` files to describe dependencies of components. It would be natural to treat task assemblies as components and use associated .deps.json file to determine their dependencies. This would make the system work nicely end to end with the .NET Core CLI/SDK and VS integration.
In .NET Core 3 there's a new type [`AssemblyDependencyResolver`](https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/src/System/Runtime/Loader/AssemblyDependencyResolver.cs) which implements parsing and processing of a `.deps.json` for a component (or assembly). The usage is to create an instance of the resolver pointing to the assembly (in MSBuild case the task assembly). The resolver parses the `.deps.json` and stores the information. It exposes two methods to resolve managed and native dependencies.
It was designed to be used as the underlying piece to implement custom ALC. So it would work nicely with task isolation above.
Expand All @@ -36,3 +35,5 @@ It was designed to be used as the underlying piece to implement custom ALC. So i

## Additional consideration
* Task dependency resolution requires APIs which are only available in .NET Core 3.0 (no plan to backport), as such MSBuild will have to target netcoreapp3.0 to use these APIs.

We decided not to implement `AssemblyDependencyResolver` in the .NET Core 3.x timeframe because of the uncertain impact of the change. We should reconsider in the .NET 5 timeframe.
2 changes: 1 addition & 1 deletion src/Build.UnitTests/BackEnd/TaskExecutionHost_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1157,7 +1157,7 @@ private void InitializeHost(bool throwOnExecute)
ProjectInstance project = CreateTestProject();

TypeLoader typeLoader = new TypeLoader(IsTaskFactoryClass);
#if FEATURE_ASSEMBLY_LOADFROM
#if !FEATURE_ASSEMBLYLOADCONTEXT
AssemblyLoadInfo loadInfo = AssemblyLoadInfo.Create(Assembly.GetAssembly(typeof(TaskBuilderTestTask.TaskBuilderTestTaskFactory)).FullName, null);
#else
AssemblyLoadInfo loadInfo = AssemblyLoadInfo.Create(typeof(TaskBuilderTestTask.TaskBuilderTestTaskFactory).GetTypeInfo().FullName, null);
Expand Down
12 changes: 6 additions & 6 deletions src/Build.UnitTests/BackEnd/TaskHostConfiguration_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ public void ConstructorWithNullLocation()
);
}

#if FEATURE_ASSEMBLY_LOADFROM
#if !FEATURE_ASSEMBLYLOADCONTEXT
/// <summary>
/// Test that an exception is thrown when the path to the task assembly is empty
/// </summary>
Expand Down Expand Up @@ -267,7 +267,7 @@ public void TestTranslationWithNullDictionary()
TaskHostConfiguration deserializedConfig = packet as TaskHostConfiguration;

Assert.Equal(config.TaskName, deserializedConfig.TaskName);
#if FEATURE_ASSEMBLY_LOADFROM
#if !FEATURE_ASSEMBLYLOADCONTEXT
Assert.Equal(config.TaskLocation, deserializedConfig.TaskLocation);
#endif
Assert.Null(deserializedConfig.TaskParameters);
Expand Down Expand Up @@ -305,7 +305,7 @@ public void TestTranslationWithEmptyDictionary()
TaskHostConfiguration deserializedConfig = packet as TaskHostConfiguration;

Assert.Equal(config.TaskName, deserializedConfig.TaskName);
#if FEATURE_ASSEMBLY_LOADFROM
#if !FEATURE_ASSEMBLYLOADCONTEXT
Assert.Equal(config.TaskLocation, deserializedConfig.TaskLocation);
#endif
Assert.NotNull(deserializedConfig.TaskParameters);
Expand Down Expand Up @@ -348,7 +348,7 @@ public void TestTranslationWithValueTypesInDictionary()
TaskHostConfiguration deserializedConfig = packet as TaskHostConfiguration;

Assert.Equal(config.TaskName, deserializedConfig.TaskName);
#if FEATURE_ASSEMBLY_LOADFROM
#if !FEATURE_ASSEMBLYLOADCONTEXT
Assert.Equal(config.TaskLocation, deserializedConfig.TaskLocation);
#endif
Assert.NotNull(deserializedConfig.TaskParameters);
Expand Down Expand Up @@ -389,7 +389,7 @@ public void TestTranslationWithITaskItemInDictionary()
TaskHostConfiguration deserializedConfig = packet as TaskHostConfiguration;

Assert.Equal(config.TaskName, deserializedConfig.TaskName);
#if FEATURE_ASSEMBLY_LOADFROM
#if !FEATURE_ASSEMBLYLOADCONTEXT
Assert.Equal(config.TaskLocation, deserializedConfig.TaskLocation);
#endif
Assert.NotNull(deserializedConfig.TaskParameters);
Expand Down Expand Up @@ -429,7 +429,7 @@ public void TestTranslationWithITaskItemArrayInDictionary()
TaskHostConfiguration deserializedConfig = packet as TaskHostConfiguration;

Assert.Equal(config.TaskName, deserializedConfig.TaskName);
#if FEATURE_ASSEMBLY_LOADFROM
#if !FEATURE_ASSEMBLYLOADCONTEXT
Assert.Equal(config.TaskLocation, deserializedConfig.TaskLocation);
#endif
Assert.NotNull(deserializedConfig.TaskParameters);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace Microsoft.Build.BackEnd.SdkResolution
{
internal class SdkResolverLoader
{
#if !FEATURE_ASSEMBLY_LOADFROM
#if FEATURE_ASSEMBLYLOADCONTEXT
private readonly CoreClrAssemblyLoader _loader = new CoreClrAssemblyLoader();
#endif

Expand Down Expand Up @@ -135,10 +135,9 @@ protected virtual IEnumerable<Type> GetResolverTypes(Assembly assembly)

protected virtual Assembly LoadResolverAssembly(string resolverPath, LoggingContext loggingContext, ElementLocation location)
{
#if FEATURE_ASSEMBLY_LOADFROM
#if !FEATURE_ASSEMBLYLOADCONTEXT
return Assembly.LoadFrom(resolverPath);
#else
_loader.AddDependencyLocation(Path.GetDirectoryName(resolverPath));
return _loader.LoadFromPath(resolverPath);
#endif
}
Expand Down
3 changes: 3 additions & 0 deletions src/Build/Microsoft.Build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,9 @@
<Compile Include="..\Shared\TypeLoader.cs">
<Link>SharedUtilities\TypeLoader.cs</Link>
</Compile>
<Compile Include="..\Shared\MSBuildLoadContext.cs" Condition="'$(TargetFrameworkIdentifier)'!='.NETFramework'">
<Link>SharedUtilities\MSBuildLoadContext.cs</Link>
</Compile>
<Compile Include="..\Shared\VisualStudioConstants.cs">
<Link>VisualStudioConstants.cs</Link>
</Compile>
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.BeforeCommon.targets
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
<FeatureAppDomain>true</FeatureAppDomain>
<DefineConstants>$(DefineConstants);FEATURE_APPDOMAIN_UNHANDLED_EXCEPTION</DefineConstants>
<DefineConstants>$(DefineConstants);FEATURE_ASPNET_COMPILER</DefineConstants>
<DefineConstants>$(DefineConstants);FEATURE_ASSEMBLY_LOADFROM</DefineConstants>
<DefineConstants>$(DefineConstants);FEATURE_ASSEMBLY_LOCATION</DefineConstants>
<DefineConstants>$(DefineConstants);FEATURE_ASSEMBLY_GETENTRYASSEMBLY</DefineConstants>
<DefineConstants>$(DefineConstants);FEATURE_ASSEMBLYNAME_CLONE</DefineConstants>
Expand Down Expand Up @@ -120,6 +119,7 @@

<PropertyGroup Condition="'$(NetCoreBuild)'=='true'">
<CompilerToolsDir>$([System.IO.Path]::Combine($(ToolPackagesDir)Microsoft.Net.Compilers, $(CompilerToolsVersion), "tools"))$([System.IO.Path]::DirectorySeparatorChar)</CompilerToolsDir>
<DefineConstants>$(DefineConstants);FEATURE_ASSEMBLYLOADCONTEXT</DefineConstants>
<DefineConstants>$(DefineConstants);FEATURE_PROCESSSTARTINFO_ENVIRONMENT</DefineConstants>
<DefineConstants>$(DefineConstants);FEATURE_RUNTIMEINFORMATION</DefineConstants>
<DefineConstants>$(DefineConstants);USE_MSBUILD_DLL_EXTN</DefineConstants>
Expand Down
46 changes: 46 additions & 0 deletions src/MSBuild.UnitTests/ValidateAssemblyLoadContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

#if FEATURE_ASSEMBLYLOADCONTEXT

using Microsoft.Build.Framework;
using Microsoft.Build.Shared;
using Microsoft.Build.Utilities;
using System.Runtime.Loader;

namespace Microsoft.Build.UnitTests
{
public class ValidateAssemblyLoadContext : Task
{
public override bool Execute()
{
var thisLoadContext = AssemblyLoadContext.GetLoadContext(typeof(ValidateAssemblyLoadContext).Assembly);

// The straightforward implementation of this check:
// if (thisLoadContext is MSBuildLoadContext context)
// fails here because MSBuildLoadContext (in this test assembly) is from MSBuild.exe via
// IVT, but the one that actually gets used for task isolation is in Microsoft.Build.dll.
// This probably doesn't need to be how it is forever: https://github.com/microsoft/msbuild/issues/5041
if (thisLoadContext.GetType().FullName == typeof(MSBuildLoadContext).FullName)
{
#if NETCOREAPP && !NETCOREAPP2_1 // TODO: enable this functionality when targeting .NET Core 3.0+
if (!thisLoadContext.Name.EndsWith(typeof(ValidateAssemblyLoadContext).Assembly.GetName().Name + ".dll"))
{
Log.LogError($"Unexpected AssemblyLoadContext name: \"{thisLoadContext.Name}\", but the current executing assembly was {typeof(ValidateAssemblyLoadContext).Assembly.GetName().Name}");
}
else
{
Log.LogMessage(MessageImportance.High, $"Task {nameof(ValidateAssemblyLoadContext)} loaded in AssemblyLoadContext named {thisLoadContext.Name}");
}
#endif
}
else
{
Log.LogError($"Load context was a {thisLoadContext.GetType().AssemblyQualifiedName} instead of an {typeof(MSBuildLoadContext).AssemblyQualifiedName}");
}

return !Log.HasLoggedErrors;
}
}
}
#endif
29 changes: 29 additions & 0 deletions src/MSBuild.UnitTests/XMake_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
using Xunit.Abstractions;
using Shouldly;
using System.IO.Compression;
using System.Reflection;

namespace Microsoft.Build.UnitTests
{
Expand Down Expand Up @@ -2119,6 +2120,34 @@ public void BinaryLogContainsImportedFiles()
}
}

#if FEATURE_ASSEMBLYLOADCONTEXT
/// <summary>
/// Ensure that tasks get loaded into their own <see cref="System.Runtime.Loader.AssemblyLoadContext"/>.
/// </summary>
/// <remarks>
/// When loading a task from a test assembly in a test within that assembly, the assembly is already loaded
/// into the default context. So put the test here and isolate the task into an MSBuild that runs in its
/// own process, causing it to newly load the task (test) assembly in a new ALC.
/// </remarks>
[Fact]
public void TasksGetAssemblyLoadContexts()
{
string customTaskPath = Assembly.GetExecutingAssembly().Location;

string projectContents = $@"<Project ToolsVersion=`msbuilddefaulttoolsversion` xmlns=`msbuildnamespace`>
<UsingTask TaskName=`ValidateAssemblyLoadContext` AssemblyFile=`{customTaskPath}` />

<Target Name=`Build`>
<ValidateAssemblyLoadContext />
</Target>
</Project>";

ExecuteMSBuildExeExpectSuccess(projectContents);
}

#endif


private string CopyMSBuild()
{
string dest = null;
Expand Down
1 change: 1 addition & 0 deletions src/MSBuild/MSBuild.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
<Compile Include="..\Shared\OutOfProcTaskHostTaskResult.cs" />
<Compile Include="..\Shared\TaskHostTaskCancelled.cs" />
<Compile Include="..\Shared\TaskLoader.cs" />
<Compile Include="..\Shared\MSBuildLoadContext.cs" Condition="'$(TargetFrameworkIdentifier)'!='.NETFramework'" />
<Compile Include="..\Shared\TypeLoader.cs" />
<Compile Include="..\Shared\LoadedType.cs">
<ExcludeFromStyleCop>true</ExcludeFromStyleCop>
Expand Down
4 changes: 2 additions & 2 deletions src/Shared/AssemblyNameExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
using System.Configuration.Assemblies;
using System.Runtime.Serialization;
using System.IO;
#if !FEATURE_ASSEMBLY_LOADFROM
#if FEATURE_ASSEMBLYLOADCONTEXT
using System.Reflection.PortableExecutable;
using System.Reflection.Metadata;
#endif
Expand Down Expand Up @@ -183,7 +183,7 @@ private AssemblyNameExtension(SerializationInfo info, StreamingContext context)
internal static AssemblyNameExtension GetAssemblyNameEx(string path)
{
AssemblyName assemblyName = null;
#if FEATURE_ASSEMBLY_LOADFROM
#if !FEATURE_ASSEMBLYLOADCONTEXT
try
{
assemblyName = AssemblyName.GetAssemblyName(path);
Expand Down
Loading