MSBuild 16.0 not finding its own dependent assemblies when loaded through MSBuildLocator

1.1k views Asked by At

I am trying to run MSBuild programmatically from a C# DLL (which will ultimately be loaded from PowerShell), and as a first step from a command-line application. I have used Microsoft.Build.Locator as recommended (or so I reckon) by installing its NuGet package to my project, and adding the following references to my test project:

    <Reference Include="Microsoft.Build, Version=15.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
      <Private>False</Private>
    </Reference>
    <Reference Include="Microsoft.Build.Framework, Version=15.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
      <Private>False</Private>
    </Reference>
    <Reference Include="Microsoft.Build.Locator, Version=1.0.0.0, Culture=neutral, PublicKeyToken=9dff12846e04bfbd, processorArchitecture=MSIL">
      <HintPath>..\packages\Microsoft.Build.Locator.1.2.6\lib\net46\Microsoft.Build.Locator.dll</HintPath>
    </Reference>

The project targets .NET Framework 4.8, and the source code is as follows:

using Microsoft.Build.Evaluation;
using Microsoft.Build.Execution;
using Microsoft.Build.Locator;
using System.Collections.Generic;

namespace nrm_testing
{
    class Program
    {
        static void Main(string[] args)
        {
            MSBuildLocator.RegisterDefaults();
            DoStuff();
        }
        
        static void DoStuff()
        {
            using (var projectCollection = new ProjectCollection())
            {
                var buildParameters = new BuildParameters
                {
                    MaxNodeCount = 1 // https://stackoverflow.com/q/62658963/3233393
                };
                
                var buildRequestData = new BuildRequestData(
                    @"path\to\a\project.vcxproj",
                    new Dictionary<string, string>(),
                    null,
                    new string[0],
                    null
                );

                var result = BuildManager.DefaultBuildManager.Build(buildParameters, buildRequestData);
            }
        }
    }
}

Upon entering the using block, I receive the following exception:

System.IO.FileNotFoundException: 'Could not load file or assembly 'System.Runtime.CompilerServices.Unsafe, Version=4.0.4.1, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a' or one of its dependencies. The system cannot find the file specified.'

The Modules window shows that MSBL did successfully locate my VS2019 installation:

Microsoft.Build.dll           16.07.0.37604 C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\Microsoft.Build.dll
Microsoft.Build.Framework.dll 16.07.0.37604 C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\Microsoft.Build.Framework.dll
Microsoft.Build.Locator.dll   1.02.6.49918  C:\dev\nrm3-tests\nrm\nrm-testing\.out\AnyCPU-Debug\Microsoft.Build.Locator.dll

System.Runtime.CompilerServices.Unsafe.dll is indeed present besides the located MSBuild assemblies, in version 4.0.6.0 (according to DotPeek).

What could be causing this error, and how could I fix it?


My attempts so far:

  • I have found this question, but the linked GitHub issue is still open and I'm unsure whether it's the same problem.

  • I have managed to get a binding redirection working, but I don't think I can use them from within a DLL, so that's a dead end.

  • Adding the System.Runtime.CompilerServices.Unsafe NuGet package to the project (and verifying that it is indeed copied alongside the project's executable) does nothing (thanks magicandre1981 for the suggestion).

  • Switching from packages.config to PackageReference (as suggested by Perry Qian), with no change in behaviour.

2

There are 2 answers

1
Quentin On BEST ANSWER

After a lot of fiddling with different ideas, I ended up writing this workaround based on manual assembly resolution.

RegisterMSBuildAssemblyPath detects when Microsoft.Build.dll gets loaded, and memorizes its directory. Upon subsequent assembly load failures, RedirectMSBuildAssemblies checks if the missing assembly exists inside that path, and loads it if it does.

class Program
{
    private static string MSBuildAssemblyDir;

    static void Main(string[] args)
    {
        MSBuildLocator.RegisterDefaults();

        Thread.GetDomain().AssemblyLoad += RegisterMSBuildAssemblyPath;
        Thread.GetDomain().AssemblyResolve += RedirectMSBuildAssemblies;

        DoStuff();
    }

    private static void RegisterMSBuildAssemblyPath(object sender, AssemblyLoadEventArgs args)
    {
        var assemblyPath = args.LoadedAssembly.Location;

        if (Path.GetFileName(assemblyPath) == "Microsoft.Build.dll")
            MSBuildAssemblyDir = Path.GetDirectoryName(assemblyPath);
    }

    private static Assembly RedirectMSBuildAssemblies(object sender, ResolveEventArgs args)
    {
        if (MSBuildAssemblyDir == null)
            return null;

        try
        {
            var assemblyFilename = $"{args.Name.Split(',')[0]}.dll";
            var potentialAssemblyPath = Path.Combine(MSBuildAssemblyDir, assemblyFilename);

            return Assembly.LoadFrom(potentialAssemblyPath);
        }
        catch (Exception)
        {
            return null;
        }
    }

    static void DoStuff()
    {
        // Same as before
    }
}

I'm pretty sure there are (many) corner cases that will make this fail, but it will do for now.

1
Mr Qian On

Actually, this is an real issue for a long time for packages.config nuget management format. And Microsoft's recommended solution for this problem is to add a bindingRedirect.

Usually, you can use this node in xxx.csproj file to automatically generate bindingredirect.

However, for some specific dlls, this node may not work due to serveral reasons. And it is still an issue on the current packages.config nuget management format as your said.

Suggestion

As a suggestion, you could use the new PackageReference nuget manage format instead since VS2017. This format is simple, convenient and efficient.

Also, when you use this format, first, you should make a backup of your project.

Just right-click on the packages.config file-->click Migrate packages.config to PackageReference.

Besides, I have also reported this issue on DC Forum and I hope the team will provide a better suggestion.