File: HotReload\HotReloadAgent.cs
Web Access
Project: src\src\Components\WebAssembly\WebAssembly\src\Microsoft.AspNetCore.Components.WebAssembly.csproj (Microsoft.AspNetCore.Components.WebAssembly)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
// Based on the implementation in https://raw.githubusercontent.com/dotnet/sdk/aad0424c0bfaa60c8bd136a92fd131e53d14561a/src/BuiltInTools/DotNetDeltaApplier/HotReloadAgent.cs
 
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Reflection.Metadata;
 
namespace Microsoft.Extensions.HotReload;
 
internal sealed class HotReloadAgent : IDisposable
{
    /// Flags for hot reload handler Types like MVC's HotReloadService.
    private const DynamicallyAccessedMemberTypes HotReloadHandlerLinkerFlags = DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods;
 
    private readonly Action<string> _log;
    private readonly AssemblyLoadEventHandler _assemblyLoad;
    private readonly ConcurrentDictionary<Guid, List<UpdateDelta>> _deltas = new();
    private readonly ConcurrentDictionary<Assembly, Assembly> _appliedAssemblies = new();
    private volatile UpdateHandlerActions? _handlerActions;
 
    public HotReloadAgent(Action<string> log)
    {
        _log = log;
        _assemblyLoad = OnAssemblyLoad;
        AppDomain.CurrentDomain.AssemblyLoad += _assemblyLoad;
    }
 
    private void OnAssemblyLoad(object? _, AssemblyLoadEventArgs eventArgs)
    {
        _handlerActions = null;
        var loadedAssembly = eventArgs.LoadedAssembly;
        var moduleId = TryGetModuleId(loadedAssembly);
        if (moduleId is null)
        {
            return;
        }
 
        if (_deltas.TryGetValue(moduleId.Value, out var updateDeltas) && _appliedAssemblies.TryAdd(loadedAssembly, loadedAssembly))
        {
            // A delta for this specific Module exists and we haven't called ApplyUpdate on this instance of Assembly as yet.
            ApplyDeltas(loadedAssembly, updateDeltas);
        }
    }
 
    internal sealed class UpdateHandlerActions
    {
        public List<Action<Type[]?>> ClearCache { get; } = new();
        public List<Action<Type[]?>> UpdateApplication { get; } = new();
    }
 
    [UnconditionalSuppressMessage("Trimmer", "IL2072",
        Justification = "The handlerType passed to GetHandlerActions is preserved by MetadataUpdateHandlerAttribute with DynamicallyAccessedMemberTypes.All.")]
    private UpdateHandlerActions GetMetadataUpdateHandlerActions()
    {
        // We need to execute MetadataUpdateHandlers in a well-defined order. For v1, the strategy that is used is to topologically
        // sort assemblies so that handlers in a dependency are executed before the dependent (e.g. the reflection cache action
        // in System.Private.CoreLib is executed before System.Text.Json clears it's own cache.)
        // This would ensure that caches and updates more lower in the application stack are up to date
        // before ones higher in the stack are recomputed.
        var sortedAssemblies = TopologicalSort(AppDomain.CurrentDomain.GetAssemblies());
        var handlerActions = new UpdateHandlerActions();
        foreach (var assembly in sortedAssemblies)
        {
            foreach (var attr in assembly.GetCustomAttributesData())
            {
                // Look up the attribute by name rather than by type. This would allow netstandard targeting libraries to
                // define their own copy without having to cross-compile.
                if (attr.AttributeType.FullName != "System.Reflection.Metadata.MetadataUpdateHandlerAttribute")
                {
                    continue;
                }
 
                IList<CustomAttributeTypedArgument> ctorArgs = attr.ConstructorArguments;
                if (ctorArgs.Count != 1 ||
                    ctorArgs[0].Value is not Type handlerType)
                {
                    _log($"'{attr}' found with invalid arguments.");
                    continue;
                }
 
                GetHandlerActions(handlerActions, handlerType);
            }
        }
 
        return handlerActions;
    }
 
    internal void GetHandlerActions(
        UpdateHandlerActions handlerActions,
        [DynamicallyAccessedMembers(HotReloadHandlerLinkerFlags)] Type handlerType)
    {
        bool methodFound = false;
 
        if (GetUpdateMethod(handlerType, "ClearCache") is MethodInfo clearCache)
        {
            handlerActions.ClearCache.Add(CreateAction(clearCache));
            methodFound = true;
        }
 
        if (GetUpdateMethod(handlerType, "UpdateApplication") is MethodInfo updateApplication)
        {
            handlerActions.UpdateApplication.Add(CreateAction(updateApplication));
            methodFound = true;
        }
 
        if (!methodFound)
        {
            _log($"No invokable methods found on metadata handler type '{handlerType}'. " +
                $"Allowed methods are ClearCache, UpdateApplication");
        }
 
        Action<Type[]?> CreateAction(MethodInfo update)
        {
            Action<Type[]?> action = update.CreateDelegate<Action<Type[]?>>();
            return types =>
            {
                try
                {
                    action(types);
                }
                catch (Exception ex)
                {
                    _log($"Exception from '{action}': {ex}");
                }
            };
        }
 
        MethodInfo? GetUpdateMethod([DynamicallyAccessedMembers(HotReloadHandlerLinkerFlags)] Type handlerType, string name)
        {
            if (handlerType.GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, new[] { typeof(Type[]) }) is MethodInfo updateMethod &&
                updateMethod.ReturnType == typeof(void))
            {
                return updateMethod;
            }
 
            foreach (MethodInfo method in handlerType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
            {
                if (method.Name == name)
                {
                    _log($"Type '{handlerType}' has method '{method}' that does not match the required signature.");
                    break;
                }
            }
 
            return null;
        }
    }
 
    internal static List<Assembly> TopologicalSort(Assembly[] assemblies)
    {
        var sortedAssemblies = new List<Assembly>(assemblies.Length);
 
        var visited = new HashSet<string>(StringComparer.Ordinal);
 
        foreach (var assembly in assemblies)
        {
            Visit(assemblies, assembly, sortedAssemblies, visited);
        }
 
        [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Hot reload is only expected to work when trimming is disabled.")]
        static void Visit(Assembly[] assemblies, Assembly assembly, List<Assembly> sortedAssemblies, HashSet<string> visited)
        {
            var assemblyIdentifier = assembly.GetName().Name!;
            if (!visited.Add(assemblyIdentifier))
            {
                return;
            }
 
            foreach (var dependencyName in assembly.GetReferencedAssemblies())
            {
                var dependency = Array.Find(assemblies, a => a.GetName().Name == dependencyName.Name);
                if (dependency is not null)
                {
                    Visit(assemblies, dependency, sortedAssemblies, visited);
                }
            }
 
            sortedAssemblies.Add(assembly);
        }
 
        return sortedAssemblies;
    }
 
    public void ApplyDeltas(IReadOnlyList<UpdateDelta> deltas)
    {
        for (var i = 0; i < deltas.Count; i++)
        {
            var item = deltas[i];
            foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
            {
                if (TryGetModuleId(assembly) is Guid moduleId && moduleId == item.ModuleId)
                {
                    MetadataUpdater.ApplyUpdate(assembly, item.MetadataDelta, item.ILDelta, item.PdbBytes ?? ReadOnlySpan<byte>.Empty);
                }
            }
 
            // Additionally stash the deltas away so it may be applied to assemblies loaded later.
            var cachedDeltas = _deltas.GetOrAdd(item.ModuleId, static _ => new());
            cachedDeltas.Add(item);
        }
 
        try
        {
            // Defer discovering metadata updata handlers until after hot reload deltas have been applied.
            // This should give enough opportunity for AppDomain.GetAssemblies() to be sufficiently populated.
            _handlerActions ??= GetMetadataUpdateHandlerActions();
            var handlerActions = _handlerActions;
 
            Type[]? updatedTypes = GetMetadataUpdateTypes(deltas);
 
            handlerActions.ClearCache.ForEach(a => a(updatedTypes));
            handlerActions.UpdateApplication.ForEach(a => a(updatedTypes));
 
            _log("Deltas applied.");
        }
        catch (Exception ex)
        {
            _log(ex.ToString());
        }
    }
 
    [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Hot reload is only expected to work when trimming is disabled.")]
    private static Type[] GetMetadataUpdateTypes(IReadOnlyList<UpdateDelta> deltas)
    {
        List<Type>? types = null;
 
        foreach (var delta in deltas)
        {
            var assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(assembly => TryGetModuleId(assembly) is Guid moduleId && moduleId == delta.ModuleId);
            if (assembly is null)
            {
                continue;
            }
 
            var assemblyTypes = assembly.GetTypes();
 
            foreach (var updatedType in delta.UpdatedTypes ?? Array.Empty<int>())
            {
                var type = assemblyTypes.FirstOrDefault(t => t.MetadataToken == updatedType);
                if (type != null)
                {
                    types ??= new();
                    types.Add(type);
                }
            }
        }
 
        return types?.ToArray() ?? Type.EmptyTypes;
    }
 
    public void ApplyDeltas(Assembly assembly, IReadOnlyList<UpdateDelta> deltas)
    {
        try
        {
            foreach (var item in deltas)
            {
                MetadataUpdater.ApplyUpdate(assembly, item.MetadataDelta, item.ILDelta, ReadOnlySpan<byte>.Empty);
            }
 
            _log("Deltas applied.");
        }
        catch (Exception ex)
        {
            _log(ex.ToString());
        }
    }
 
    public void Dispose()
    {
        AppDomain.CurrentDomain.AssemblyLoad -= _assemblyLoad;
    }
 
    private static Guid? TryGetModuleId(Assembly loadedAssembly)
    {
        try
        {
            return loadedAssembly.Modules.FirstOrDefault()?.ModuleVersionId;
        }
        catch
        {
            // Assembly.Modules might throw. See https://github.com/dotnet/aspnetcore/issues/33152
        }
 
        return default;
    }
}