|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using Microsoft.Build.Shared.FileSystem;
namespace Microsoft.Build.Shared
{
/// <summary>
/// Utilities class that provides common functionality for task factories such as
/// temporary assembly creation and load manifest generation.
///
/// This class consolidates duplicate logic that was previously scattered across:
/// - RoslynCodeTaskFactory
/// - CodeTaskFactory
/// - XamlTaskFactory
///
/// The common patterns include:
/// 1. Creating process-specific temporary directories for inline task assemblies
/// 2. Generating load manifest files for out-of-process task execution
/// 3. Loading assemblies based on execution mode (in-process vs out-of-process)
/// 4. Assembly resolution for custom reference locations
/// </summary>
internal static class TaskFactoryUtilities
{
/// <summary>
/// The sub-path within the temporary directory where compiled inline tasks are located.
/// </summary>
public const string InlineTaskTempDllSubPath = nameof(InlineTaskTempDllSubPath);
public const string InlineTaskSuffix = "inline_task.dll";
public const string InlineTaskLoadManifestSuffix = ".loadmanifest";
/// <summary>
/// Represents a cached assembly entry for task factories with validation support.
/// </summary>
public readonly struct CachedAssemblyEntry
{
public CachedAssemblyEntry(Assembly assembly, string assemblyPath)
{
Assembly = assembly;
AssemblyPath = assemblyPath;
}
public Assembly Assembly { get; }
public string AssemblyPath { get; }
/// <summary>
/// Validates that the cached assembly is still usable.
/// For out-of-process scenarios (when AssemblyPath is specified), validates the file exists.
/// For in-process scenarios (when AssemblyPath is empty), always considers valid.
/// </summary>
public bool IsValid => string.IsNullOrEmpty(AssemblyPath) || FileUtilities.FileExistsNoThrow(AssemblyPath);
}
/// <summary>
/// Creates a process-specific temporary directory for inline task assemblies.
/// </summary>
/// <returns>The path to the created temporary directory.</returns>
public static string CreateProcessSpecificTemporaryTaskDirectory()
{
string processSpecificInlineTaskDir = Path.Combine(
FileUtilities.TempFileDirectory,
InlineTaskTempDllSubPath,
$"pid_{EnvironmentUtilities.CurrentProcessId}");
Directory.CreateDirectory(processSpecificInlineTaskDir);
return processSpecificInlineTaskDir;
}
/// <summary>
/// Gets a temporary file path for an inline task assembly in the process-specific directory.
/// </summary>
/// <returns>The full path to the temporary file.</returns>
public static string GetTemporaryTaskAssemblyPath()
{
string taskDir = CreateProcessSpecificTemporaryTaskDirectory();
return FileUtilities.GetTemporaryFile(taskDir, fileName: null, extension: "inline_task.dll", createFile: false);
}
/// <summary>
/// Creates a load manifest file containing directories that should be added to the assembly resolution path
/// for out-of-process task execution.
/// </summary>
/// <param name="assemblyPath">The path to the task assembly.</param>
/// <param name="directoriesToAdd">The list of directories to include in the manifest.</param>
/// <returns>The path to the created manifest file.</returns>
public static string CreateLoadManifest(string assemblyPath, List<string> directoriesToAdd)
{
if (string.IsNullOrEmpty(assemblyPath))
{
throw new ArgumentException("Assembly path cannot be null or empty.", nameof(assemblyPath));
}
if (directoriesToAdd == null)
{
throw new ArgumentNullException(nameof(directoriesToAdd));
}
string manifestPath = assemblyPath + InlineTaskLoadManifestSuffix;
File.WriteAllLines(manifestPath, directoriesToAdd);
return manifestPath;
}
/// <summary>
/// Creates a load manifest file from reference assembly paths by extracting their directories.
/// This is a convenience method that extracts unique directories from assembly paths and creates the manifest.
/// </summary>
/// <param name="assemblyPath">The path to the task assembly.</param>
/// <param name="referenceAssemblyPaths">The list of reference assembly paths to extract directories from.</param>
/// <returns>The path to the created manifest file, or null if no valid directories were found.</returns>
public static string? CreateLoadManifestFromReferences(string assemblyPath, List<string> referenceAssemblyPaths)
{
if (string.IsNullOrEmpty(assemblyPath))
{
throw new ArgumentException("Assembly path cannot be null or empty.", nameof(assemblyPath));
}
if (referenceAssemblyPaths == null)
{
throw new ArgumentNullException(nameof(referenceAssemblyPaths));
}
var directories = ExtractUniqueDirectoriesFromAssemblyPaths(referenceAssemblyPaths);
if (directories.Count == 0)
{
return null;
}
return CreateLoadManifest(assemblyPath, directories);
}
/// <summary>
/// Extracts unique directories from a collection of assembly file paths.
/// Only includes directories for assemblies that actually exist on disk.
/// </summary>
/// <param name="assemblyPaths">The collection of assembly file paths.</param>
/// <returns>A list of unique directory paths in order of first occurrence.</returns>
public static List<string> ExtractUniqueDirectoriesFromAssemblyPaths(List<string> assemblyPaths)
{
if (assemblyPaths == null)
{
throw new ArgumentNullException(nameof(assemblyPaths));
}
var directories = new List<string>();
var seenDirectories = new HashSet<string>(FileUtilities.PathComparer);
foreach (string assemblyPath in assemblyPaths)
{
if (!string.IsNullOrEmpty(assemblyPath) && FileSystems.Default.FileExists(assemblyPath))
{
string? directory = Path.GetDirectoryName(assemblyPath);
if (!string.IsNullOrEmpty(directory) && seenDirectories.Add(directory))
{
directories.Add(directory);
}
}
}
return directories;
}
/// <summary>
/// Loads an assembly from the specified path.
/// </summary>
/// <param name="assemblyPath">The path to the assembly to load.</param>
/// <returns>The loaded assembly.</returns>
public static Assembly LoadTaskAssembly(string assemblyPath)
{
if (string.IsNullOrEmpty(assemblyPath))
{
throw new ArgumentException("Assembly path cannot be null or empty.", nameof(assemblyPath));
}
// Load the assembly from bytes so we don't lock the file and record its original path for out-of-proc hosts
Assembly assembly = Assembly.Load(FileSystems.Default.ReadFileAllBytes(assemblyPath));
return assembly;
}
/// <summary>
/// Registers assembly resolution handlers for inline tasks based on their load manifest file.
/// This enables out-of-process task execution to resolve dependencies that were identified
/// during TaskFactory initialization.
/// </summary>
/// <param name="taskLocation">The path to the task assembly.</param>
public static void RegisterAssemblyResolveHandlersFromManifest(string taskLocation)
{
if (string.IsNullOrEmpty(taskLocation))
{
throw new ArgumentException("Task location cannot be null or empty.", nameof(taskLocation));
}
if (!taskLocation.EndsWith(InlineTaskSuffix, StringComparison.OrdinalIgnoreCase))
{
return;
}
string manifestPath = taskLocation + InlineTaskLoadManifestSuffix;
if (!FileSystems.Default.FileExists(manifestPath))
{
return;
}
string[] directories = File.ReadAllLines(manifestPath);
if (directories?.Length > 0)
{
ResolveEventHandler resolver = CreateAssemblyResolver([.. directories]);
AppDomain.CurrentDomain.AssemblyResolve += resolver;
}
}
/// <summary>
/// Creates an assembly resolution event handler that can resolve assemblies from a list of directories.
/// This is typically used for in-memory compiled task assemblies that have custom reference locations.
/// </summary>
/// <param name="searchDirectories">The directories to search for assemblies.</param>
/// <returns>A ResolveEventHandler that can be used with AppDomain.CurrentDomain.AssemblyResolve.</returns>
public static ResolveEventHandler CreateAssemblyResolver(List<string> searchDirectories)
{
if (searchDirectories == null)
{
throw new ArgumentNullException(nameof(searchDirectories));
}
return (sender, args) => TryLoadAssembly(searchDirectories, new AssemblyName(args.Name));
}
/// <summary>
/// Cleans up the current process's inline task directory by deleting the temporary directory
/// and its contents used for inline task assemblies for this specific process.
/// This should be called at the end of a build to prevent dangling DLL files.
/// </summary>
/// <remarks>
/// On Windows platforms, this may fail to delete files that are still locked by the current process.
/// However, it will clean up any files that are no longer in use.
/// </remarks>
public static void CleanCurrentProcessInlineTaskDirectory()
{
string processSpecificInlineTaskDir = Path.Combine(
FileUtilities.TempFileDirectory,
InlineTaskTempDllSubPath,
$"pid_{EnvironmentUtilities.CurrentProcessId}");
if (FileSystems.Default.DirectoryExists(processSpecificInlineTaskDir))
{
FileUtilities.DeleteDirectoryNoThrow(processSpecificInlineTaskDir, recursive: true);
}
}
/// <summary>
/// Attempts to load an assembly by searching in the specified directories.
/// </summary>
/// <param name="directories">The directories to search in.</param>
/// <param name="assemblyName">The name of the assembly to load.</param>
/// <returns>The loaded assembly if found, otherwise null.</returns>
private static Assembly? TryLoadAssembly(List<string> directories, AssemblyName assemblyName)
{
foreach (string directory in directories)
{
string path;
// Try culture-specific path first if the assembly has a culture
if (!string.IsNullOrEmpty(assemblyName.CultureName))
{
path = Path.Combine(directory, assemblyName.CultureName, assemblyName.Name + ".dll");
if (FileSystems.Default.FileExists(path))
{
return Assembly.Load(FileSystems.Default.ReadFileAllBytes(path));
}
}
// Try the standard path
path = Path.Combine(directory, assemblyName.Name + ".dll");
if (FileSystems.Default.FileExists(path))
{
return Assembly.Load(FileSystems.Default.ReadFileAllBytes(path));
}
}
return null;
}
}
}
|