Skip to content
Draft
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
31 changes: 27 additions & 4 deletions .agents/skills/msbuild-loader-netframework/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,39 @@ and cross-cutting conventions, see `AGENTS.md`.
## Assembly-resolution handler
- `s_registeredHandler` is a static `ResolveEventHandler`; `IsRegistered` is
`s_registeredHandler != null`.
- `RegisterMSBuildPathsInternally` parses an MSBuild configuration file (preferring
`amd64\MSBuild.exe.config`, falling back to `MSBuild.exe.config` in the registered path).
The config policy is read once. The selected config path is made absolute during
registration, so relative `codeBase` paths stay anchored to that config directory
even if the process working directory later changes.
- `RegisterMSBuildPathsInternally` stores the handler in the static field before
subscribing to `AppDomain.CurrentDomain.AssemblyResolve`; the event subscription
keeps the delegate alive, while the field tracks registration state.
- `AssemblyResolve` can fire repeatedly for the same assembly; results are cached
in `loadedAssemblies` keyed by `AssemblyName.FullName`.
in `loadedAssemblies` under the original request's `AssemblyName.FullName`, the
effective identity for config candidates, and the loaded assembly's full identity.
- Resolution is explicitly not thread-safe; every cache lookup/load runs under
`lock (loadedAssemblies)`.
- Handler path: parse `eventArgs.Name` with `new AssemblyName(eventArgs.Name)`;
for each registered search path, if `<msbuildPath>\<Name>.dll` exists, return
`Assembly.LoadFrom(targetAssembly)`.
- Handler path: parse `eventArgs.Name` with `new AssemblyName(eventArgs.Name)`.
- Config-first ordering:
- Apply `qualifyAssembly` policy if the request has a partial name.
- Match name, token, and culture. Compare configured `processorArchitecture`
with the request when it specifies one; otherwise defer that check to the
candidate file's manifest.
- If a valid `<bindingRedirect>` applies, map to the `newVersion`.
- If a `<codeBase>` matches the effective version (whether redirected or standalone),
normalize its local path or `file://` URI, resolving relative paths against
the config directory.
- Inspect the candidate file's manifest with `AssemblyName.GetAssemblyName`
and compare its identity, including any required architecture, before
calling `Assembly.LoadFrom`. Cache the returned assembly; its identity
is not revalidated after loading.
- A missing/malformed config yields an empty policy without masking other failures.
- Fallback:
- If the config does not resolve the assembly or load fails (e.g. `FileNotFoundException`),
fall back to search-path probing (the old/custom layout behavior).
- For each registered search path, if `<msbuildPath>\<Name>.dll` exists, return
`Assembly.LoadFrom(targetAssembly)`.
- Search paths come from `RegisterMSBuildPath(...)`, or from
`RegisterInstance(...)` as `instance.MSBuildPath` plus the VS NuGet path when
it exists.
Expand Down
106 changes: 106 additions & 0 deletions src/MSBuildLocator.Tests/AssemblyResolutionRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

#if !NETCOREAPP

using System;
using System.Linq;
using System.Reflection;

namespace Microsoft.Build.Locator.Tests
{
/// <summary>
/// Drives a real registration inside a child AppDomain. Registration installs a process-wide
/// <see cref="AppDomain.AssemblyResolve"/> handler and cannot be undone, so it must not happen in the
/// AppDomain running the tests. Statics and assembly loads are per-AppDomain, so a child domain gives
/// each test a clean registration that disappears when the domain unloads.
/// </summary>
public sealed class AssemblyResolutionRunner : MarshalByRefObject
{
/// <summary>Registers <paramref name="msbuildSearchPaths"/>, returning <see langword="null"/> on success.</summary>
public string Register(string[] msbuildSearchPaths)
{
try
{
MSBuildLocator.RegisterMSBuildPath(msbuildSearchPaths);
return null;
}
catch (Exception e)
{
return e.ToString();
}
}

public string GetEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name);

/// <summary>Loads <paramref name="assemblyName"/> the way any consumer of MSBuild would.</summary>
public AssemblyLoadResult Load(string assemblyName)
{
try
{
Assembly assembly = Assembly.Load(assemblyName);
return new AssemblyLoadResult
{
Succeeded = true,
FullName = assembly.GetName().FullName,
Location = assembly.Location,
LoadedCount = CountLoaded(assembly.GetName().Name)
};
}
catch (Exception e)
{
return new AssemblyLoadResult { Error = e.ToString() };
}
}

/// <summary>
/// Loads two identities that policy binds to the same file, reporting whether the second request
/// reused the assembly the first one loaded.
/// </summary>
public AssemblyLoadResult LoadTwice(string firstAssemblyName, string secondAssemblyName)
{
try
{
Assembly first = Assembly.Load(firstAssemblyName);
Assembly second = Assembly.Load(secondAssemblyName);

return new AssemblyLoadResult
{
Succeeded = true,
FullName = second.GetName().FullName,
Location = second.Location,
LoadedCount = CountLoaded(first.GetName().Name),
SameInstance = ReferenceEquals(first, second)
};
}
catch (Exception e)
{
return new AssemblyLoadResult { Error = e.ToString() };
}
}

private static int CountLoaded(string simpleName) => AppDomain.CurrentDomain.GetAssemblies()
.Count(a => string.Equals(a.GetName().Name, simpleName, StringComparison.OrdinalIgnoreCase));
}

/// <summary>The outcome of a load attempt, marshaled back to the AppDomain running the test.</summary>
[Serializable]
public sealed class AssemblyLoadResult
{
public bool Succeeded { get; set; }

public string FullName { get; set; }

public string Location { get; set; }

/// <summary>Number of assemblies with the loaded simple name in the child AppDomain.</summary>
public int LoadedCount { get; set; }

/// <summary>Whether two requested identities resolved to the same <see cref="Assembly"/> instance.</summary>
public bool SameInstance { get; set; }

public string Error { get; set; }
}
}

#endif
81 changes: 81 additions & 0 deletions src/MSBuildLocator.Tests/FakeVisualStudioInstall.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

#if !NETCOREAPP

using System.IO;

namespace Microsoft.Build.Locator.Tests
{
/// <summary>
/// A directory layout shaped like a Visual Studio installation that relocates MSBuild dependencies out
/// of the MSBuild bin directory, without requiring Visual Studio to be installed.
/// </summary>
internal sealed class FakeVisualStudioInstall
{
/// <summary>Relative path from the canonical amd64 config directory back to the installation root.</summary>
public const string Amd64ToRoot = @"..\..\..\..";

private FakeVisualStudioInstall(string root)
{
Root = root;
SharedAssemblies = Path.Combine(root, "SharedAssemblies");
Bin = Path.Combine(root, "MSBuild", "Current", "Bin");
Amd64 = Path.Combine(Bin, "amd64");
}

/// <summary>Root of the installation, the equivalent of <c>C:\Program Files\Microsoft Visual Studio\18\Preview</c>.</summary>
public string Root { get; }

/// <summary>Directory holding assemblies shared by the installation rather than deployed beside MSBuild.</summary>
public string SharedAssemblies { get; }

/// <summary>The 32-bit MSBuild bin directory.</summary>
public string Bin { get; }

/// <summary>The 64-bit MSBuild bin directory, which holds the canonical config.</summary>
public string Amd64 { get; }

public static FakeVisualStudioInstall Create(string root)
{
var install = new FakeVisualStudioInstall(root);

Directory.CreateDirectory(install.SharedAssemblies);
Directory.CreateDirectory(install.Amd64);

// Only the existence of MSBuild.exe matters to config discovery, so an empty file is enough. Its
// missing version resource reads as version 0, which also exercises the pre-17.1 compatibility path.
File.WriteAllText(Path.Combine(install.Bin, "MSBuild.exe"), string.Empty);
File.WriteAllText(Path.Combine(install.Amd64, "MSBuild.exe"), string.Empty);

return install;
}

/// <summary>Writes the canonical <c>amd64\MSBuild.exe.config</c> around <paramref name="bindingContent"/>.</summary>
public string WriteAmd64Config(string bindingContent) => WriteConfig(Amd64, ConfigXml(bindingContent));

/// <summary>Writes the base <c>MSBuild.exe.config</c> around <paramref name="bindingContent"/>.</summary>
public string WriteBinConfig(string bindingContent) => WriteConfig(Bin, ConfigXml(bindingContent));

/// <summary>Writes arbitrary content as the canonical config, for malformed-config tests.</summary>
public string WriteRawAmd64Config(string content) => WriteConfig(Amd64, content);

public static string ConfigXml(string bindingContent) => $@"<?xml version=""1.0"" encoding=""utf-8""?>
<configuration>
<runtime>
<assemblyBinding xmlns=""urn:schemas-microsoft-com:asm.v1"">
{bindingContent}
</assemblyBinding>
</runtime>
</configuration>";

private static string WriteConfig(string directory, string content)
{
string path = Path.Combine(directory, "MSBuild.exe.config");
File.WriteAllText(path, content);
return path;
}
}
}

#endif
57 changes: 57 additions & 0 deletions src/MSBuildLocator.Tests/FixtureAssembly.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

#if !NETCOREAPP

using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;

namespace Microsoft.Build.Locator.Tests
{
/// <summary>
/// Emits strong-named assemblies for tests that need an assembly no probing path can find. Emitting
/// keeps the fixtures out of the test output directory, which the runtime probes on its own, and avoids
/// adding a fixture project or a compiler dependency to the test project.
/// </summary>
internal static class FixtureAssembly
{
/// <summary>
/// Emits <paramref name="name"/> at <paramref name="version"/> into <paramref name="directory"/>,
/// signed with the same key as the product assemblies.
/// </summary>
/// <returns>The full path of the emitted assembly.</returns>
public static string Emit(string directory, string name, Version version)
{
Directory.CreateDirectory(directory);

var assemblyName = new AssemblyName(name)
{
Version = version,
KeyPair = new StrongNameKeyPair(File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "key.snk")))
};

AssemblyBuilder builder = AppDomain.CurrentDomain.DefineDynamicAssembly(
assemblyName,
AssemblyBuilderAccess.RunAndSave,
directory);

// A single type keeps the emitted assembly a valid, loadable image.
builder.DefineDynamicModule(name, name + ".dll")
.DefineType(name + ".Marker", TypeAttributes.Public)
.CreateType();

builder.Save(name + ".dll");

return Path.Combine(directory, name + ".dll");
}

/// <summary>Gets the public key token of an emitted assembly, formatted as a config file writes it.</summary>
public static string GetPublicKeyToken(string assemblyPath) => string.Concat(
AssemblyName.GetAssemblyName(assemblyPath).GetPublicKeyToken().Select(b => b.ToString("x2")));
}
}

#endif
Loading