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 4 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
43 changes: 43 additions & 0 deletions src/MSBuild.UnitTests/ValidateAssemblyLoadContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// 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);

// Check by name because this always reports false, presumably due to ALC shenanigans:
// if (thisLoadContext is MSBuildLoadContext context)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you file a follow up issue for that commented out section? That definitely seems like a bug or at least an item worthy of documentation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I don't understand what's going on here.

I patched it a bit to get more details (again, I think I did this before):

Log.LogMessage(MessageImportance.High, $"thisLoadContext is MSBuildLoadContext context : {thisLoadContext is MSBuildLoadContext context}");
Log.LogMessage(MessageImportance.High, $"thisLoadContext            loaded in AssemblyLoadContext {AssemblyLoadContext.GetLoadContext(thisLoadContext.GetType().Assembly)}");
Log.LogMessage(MessageImportance.High, $"typeof(MSBuildLoadContext) loaded in AssemblyLoadContext {AssemblyLoadContext.GetLoadContext(typeof(MSBuildLoadContext).Assembly)}");
  thisLoadContext is MSBuildLoadContext context : False
  thisLoadContext            loaded in AssemblyLoadContext "Default" System.Runtime.Loader.DefaultAssemblyLoadContext #2
  typeof(MSBuildLoadContext) loaded in AssemblyLoadContext "Default" System.Runtime.Loader.DefaultAssemblyLoadContext #2

My theory was that they were in different ALCs but that doesn't seem to be true.

@vitek-karas do you know what might cause this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The different ALCs would be my theory as well - but as you mention that doesn't seem to be the case. You can try to GetType on both ends of the comparison and look it up in the debugger (make object ID) to double check. If you can prove that the Type objects are the same, but "is" doesn't work - that would be a problem.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel uneasy that this isn't resolved. Please replace "shenanigans" with an explanation as what is happening. It feels like we don't even know if this is a bug or not yet.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks to Nick offline I think I now understand what's going on. Because MSBuildLoadContext is compiled as source into MSBuild.exe and Microsoft.Build.dll, there are two copies of it available at runtime. MSBuild's copy is exposed to the test assembly via IVT, but Microsoft.Build's is the one that is relevant for loading the task. So in fact they don't match--even though they have the same namespace names they have different assembly-qualified names.

I'll amend the comment to describe this--I don't think it's worth juggling IVT right now. Filing an issue to come back and look at that.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does IVT stand for? Intermediate Value Theorem?? Interrupt Vector Table?? InternalsVisibleTo?

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