/* * Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Extensions.Loading * File: LoadingExtensions.cs * * LoadingExtensions.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger * VNLib collection of libraries and utilities. * * VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ using System; using System.IO; using System.Linq; using System.Text.Json; using System.Reflection; using System.Runtime.Loader; using System.Threading.Tasks; using System.Collections.Generic; using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using VNLib.Utils.Logging; using VNLib.Utils.Resources; using VNLib.Utils.Extensions; namespace VNLib.Plugins.Extensions.Loading { /// /// Provides common loading (and unloading when required) extensions for plugins /// public static class LoadingExtensions { /// /// A key in the 'plugins' configuration object that specifies /// an asset search directory /// public const string DEBUG_CONFIG_KEY = "debug"; public const string SECRETS_CONFIG_KEY = "secrets"; public const string PASSWORD_HASHING_KEY = "passwords"; public const string CUSTOM_PASSWORD_ASM_KEY = "custom_asm"; /* * Plugin local cache used for storing singletons for a plugin instance */ private static readonly ConditionalWeakTable _localCache = new(); private static readonly ConcurrentDictionary _assemblyCache = new(); /// /// Gets a previously cached service singleton for the desired plugin /// /// The service instance type /// The plugin to obtain or build the singleton for /// The method to produce the singleton /// The cached or newly created singleton public static object GetOrCreateSingleton(PluginBase plugin, Type serviceType, Func serviceFactory) { //Get local cache PluginLocalCache pc = _localCache.GetValue(plugin, PluginLocalCache.Create); return pc.GetOrCreateService(serviceType, serviceFactory); } /// /// Gets a previously cached service singleton for the desired plugin /// or creates a new singleton instance for the plugin /// /// /// The plugin to obtain or build the singleton for /// The method to produce the singleton /// The cached or newly created singleton public static T GetOrCreateSingleton(PluginBase plugin, Func serviceFactory) => (T)GetOrCreateSingleton(plugin, typeof(T), p => serviceFactory(p)!); /// /// Gets the full file path for the assembly asset file name within the assets /// directory. /// /// /// The name of the assembly (ex: 'file.dll') to search for /// Directory search flags /// The full path to the assembly asset file, or null if the file does not exist /// public static string? GetAssetFilePath(this PluginBase plugin, string assemblyName, SearchOption searchOption) { plugin.ThrowIfUnloaded(); ArgumentNullException.ThrowIfNull(assemblyName); string[] searchDirs; /* * Allow an assets directory to limit the scope of the search for the desired * assembly, otherwise search all plugins directories */ string? assetDir = plugin.GetAssetsPath(); searchDirs = assetDir is null ? plugin.GetPluginSearchDirs() : ([assetDir]); /* * This should never happen since this method can only be called from a * plugin context, which means this path was used to load the current plugin */ if (searchDirs.Length == 0) { throw new ArgumentException("No plugin asset directory is defined for the current host configuration, this is likely a bug"); } //Get the first file that matches the search file return searchDirs.SelectMany(d => Directory.EnumerateFiles(d, assemblyName, searchOption)).FirstOrDefault(); } /// /// Loads a managed assembly into the current plugin's load context and will unload when disposed /// or the plugin is unloaded from the host application. /// /// The desired exported type to load from the assembly /// /// The name of the assembly (ex: 'file.dll') to search for /// Directory/file search option /// /// Explicitly define an to load the assmbly, and it's dependencies /// into. If null, uses the plugin's alc. /// /// The managing the loaded assmbly in the current AppDomain /// /// /// /// The assembly is searched within the 'assets' directory specified in the plugin config /// or the global plugins ('path' key) directory if an assets directory is not defined. /// public static AssemblyLoader LoadAssembly( this PluginBase plugin, string assemblyName, SearchOption dirSearchOption = SearchOption.AllDirectories, AssemblyLoadContext? explictAlc = null) { //Get the file path for the assembly string asmFile = GetAssetFilePath(plugin, assemblyName, dirSearchOption) ?? throw new FileNotFoundException($"Failed to find custom assembly {assemblyName} from plugin directory"); //Get the plugin's load context if not explicitly supplied explictAlc ??= GetPluginLoadContext(); if (plugin.IsDebug()) { plugin.Log.Verbose("Loading assembly {asm}: from file {file}", assemblyName, asmFile); } //Load the assembly return AssemblyLoader.Load(asmFile, explictAlc, plugin.UnloadToken); } /// /// Loads a managed assembly into the current plugin's load context and will unload when disposed /// or the plugin is unloaded from the host application. /// /// /// The name of the assembly (ex: 'file.dll') to search for /// Directory/file search option /// /// Explicitly define an to load the assmbly, and it's dependencies /// into. If null, uses the plugin's alc. /// /// The managing the loaded assmbly in the current AppDomain /// /// /// /// The assembly is searched within the 'assets' directory specified in the plugin config /// or the global plugins ('path' key) directory if an assets directory is not defined. /// public static ManagedLibrary LoadAssembly( this PluginBase plugin, string assemblyName, SearchOption dirSearchOption = SearchOption.AllDirectories, AssemblyLoadContext? explictAlc = null) { /* * Using an assembly loader instance instead of managed library, so it respects * the plugin's unload events. Returning the managed library instance will * hide the overloads that would cause possible type load issues, so using * an object as the generic type parameter shouldn't be an issue. */ return LoadAssembly(plugin, assemblyName, dirSearchOption, explictAlc); } /// /// Gets the current plugin's . /// /// /// public static AssemblyLoadContext GetPluginLoadContext() { /* * Since this library should only be used in a plugin context, the executing assembly * will be loaded into the plugin's isolated load context. So we can get the load * context for the executing assembly and use that as the plugin's load context. */ Assembly executingAsm = Assembly.GetExecutingAssembly(); return AssemblyLoadContext.GetLoadContext(executingAsm) ?? throw new InvalidOperationException("Could not get plugin's assembly load context"); } /// /// Gets a single type implemenation of the abstract type from the current assembly. If multiple /// concrete types are found, an exception is raised, if no concrete types are found, an exception /// is raised. /// /// The abstract type to get the concrete type from /// The concrete type if found /// /// public static Type GetTypeImplFromCurrentAssembly(Type abstractType) { //Get all types from the current assembly that implement the abstract type Assembly executingAsm = Assembly.GetExecutingAssembly(); Type[] concreteTypes = executingAsm.GetTypes().Where(t => !t.IsAbstract && abstractType.IsAssignableFrom(t)).ToArray(); if(concreteTypes.Length == 0) { throw new ConcreteTypeNotFoundException($"Failed to load implemenation of abstract type {abstractType} because no concrete implementations were found in this assembly"); } if(concreteTypes.Length > 1) { throw new ConcreteTypeAmbiguousMatchException( $"Failed to load implemenation of abstract type {abstractType} because multiple concrete implementations were found in this assembly"); } //Get the only concrete type return concreteTypes[0]; } /// /// Determintes if the current plugin config has a debug propety set /// /// /// True if debug mode is enabled, false otherwise /// public static bool IsDebug(this PluginBase plugin) { plugin.ThrowIfUnloaded(); //Check for debug element return plugin.PluginConfig.TryGetProperty(DEBUG_CONFIG_KEY, out JsonElement dbgEl) && dbgEl.GetBoolean(); } /// /// Internal exception helper to raise if the plugin has been unlaoded /// /// /// public static void ThrowIfUnloaded(this PluginBase plugin) { //See if the plugin was unlaoded ObjectDisposedException.ThrowIf(plugin.UnloadToken.IsCancellationRequested, plugin); } /// /// Schedules an asynchronous callback function to run and its results will be observed /// when the operation completes, or when the plugin is unloading /// /// /// The asynchronous operation to observe /// An optional startup delay for the operation /// A task that completes when the deferred task completes /// public static async Task ObserveWork(this PluginBase plugin, Func asyncTask, int delayMs = 0) { /* * Motivation: * Sometimes during plugin loading, a plugin may want to asynchronously load * data, where the results are not required to be observed during loading, but * should not be pending after the plugin is unloaded, as the assembly may be * unloaded and referrences collected by the GC. * * So we can use the plugin's unload cancellation token to observe the results * of a pending async operation */ //Test status plugin.ThrowIfUnloaded(); //Optional delay await Task.Delay(delayMs); //Run on ts Task deferred = Task.Run(asyncTask); //Add task to deferred list plugin.ObserveTask(deferred); try { //Await the task results await deferred.ConfigureAwait(false); } catch(Exception ex) { //Log errors plugin.Log.Error(ex, "Error occured while observing deferred task"); } finally { //Remove task when complete plugin.RemoveObservedTask(deferred); } } /// /// Schedules work to begin after the specified delay to be observed by the plugin while /// passing plugin specifie information. Exceptions are logged to the default plugin log /// /// /// The work to be observed /// The time (in milliseconds) to delay dispatching the work item /// The task that represents the scheduled work public static Task ObserveWork(this PluginBase plugin, IAsyncBackgroundWork work, int delayMs = 0) { return ObserveWork(plugin, () => work.DoWorkAsync(plugin.Log, plugin.UnloadToken), delayMs); } /// /// Registers an event to occur when the plugin is unloaded on a background thread /// and will cause the Plugin.Unload() method to block until the event completes /// /// /// The method to call when the plugin is unloaded /// A task that represents the registered work /// /// public static Task RegisterForUnload(this PluginBase plugin, Action callback) { //Test status plugin.ThrowIfUnloaded(); ArgumentNullException.ThrowIfNull(callback); //Wait method static async Task WaitForUnload(PluginBase pb, Action callback) { //Wait for unload as a task on the threadpool to avoid deadlocks await pb.UnloadToken.WaitHandle.WaitAsync() .ConfigureAwait(false); callback(); } //Registaer the task to cause the plugin to wait return plugin.ObserveWork(() => WaitForUnload(plugin, callback)); } /// /// Creates a new instance of the desired service type from an external assembly and /// caches the loaded assembly so it's never loaded more than once. Managed assembly /// life cycles are managed by the plugin. Instances are treated as services and /// their service hooks will be called like any internal service. /// /// The service type, may be an interface or abstract type /// /// The name of the assembly that contains the desired type to search for /// The directory search method /// A to load the assembly into. Defaults to the plugins current ALC /// A new instance of the desired service type /// public static T CreateServiceExternal( this PluginBase plugin, string assemblyDllName, SearchOption search = SearchOption.AllDirectories, AssemblyLoadContext? defaultCtx = null ) where T : class { /* * Get or create the library for the assembly path, but only load it once * Loading it on the plugin will also cause it be cleaned up when the plugin * is unloaded. */ ManagedLibrary manLib = _assemblyCache.GetOrAdd(assemblyDllName, (name) => LoadAssembly(plugin, name, search, defaultCtx)); Type[] matchingTypes = manLib.TryGetAllMatchingTypes().ToArray(); //try to get the first type that has the extern attribute, or fall back to the first public & concrete type Type? exported = matchingTypes.FirstOrDefault(t => t.GetCustomAttribute() != null) ?? matchingTypes.Where(t => !t.IsAbstract && t.IsPublic).FirstOrDefault(); _ = exported ?? throw new TypeLoadException($"The desired external asset type {typeof(T).Name} is not exported as part of the assembly {manLib.Assembly.FullName}"); //Try to get a configuration for the exported type if (plugin.HasConfigForType(exported)) { //Get the config for the type and create the service return (T)CreateService(plugin, exported, plugin.GetConfigForType(exported)); } //Create new instance of the desired type return (T)CreateService(plugin, exported, null); } /// /// Exports a service of the desired type to the host application. Once the plugin /// is done loading, the host will be able to access the service instance. /// /// You should avoid mutating the service instance after the plugin has been /// loaded, especially if you are using factory methods to create the service. /// /// /// /// /// The service instance to pass the host /// Optional export flags to pass to the host /// /// public static void ExportService(this PluginBase plugin, T instance, ExportFlags flags = ExportFlags.None) where T : class => ExportService(plugin, typeof(T), instance, flags); /// /// Exports a service of the desired type to the host application. Once the plugin /// is done loading, the host will be able to access the service instance. /// /// You should avoid mutating the service instance after the plugin has been /// loaded, especially if you are using factory methods to create the service. /// /// /// /// The service type to export /// The service instance to pass the host /// Optional export flags to pass to the host /// /// public static void ExportService(this PluginBase plugin, Type type, object instance, ExportFlags flags = ExportFlags.None) { ArgumentNullException.ThrowIfNull(plugin, nameof(plugin)); plugin.ThrowIfUnloaded(); //Init new service wrapper ServiceExport export = new(type, instance, flags); plugin.Services.Add(export); } /// /// /// Gets or inializes a singleton service of the desired type. /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// /// /// /// /// /// /// /// public static T GetOrCreateSingleton(this PluginBase plugin) => GetOrCreateSingleton(plugin, CreateService); /// /// /// Gets or inializes a singleton service of the desired type. /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// /// /// Overrids the default configuration property name /// The configured service singleton /// /// /// /// /// public static T GetOrCreateSingleton(this PluginBase plugin, string configName) => GetOrCreateSingleton(plugin, (plugin) => CreateService(plugin, configName)); /// /// Configures the service asynchronously on the plugin's scheduler and returns a task /// that represents the configuration work. /// /// The service type /// /// The service to configure /// The time in milliseconds to delay the configuration task /// A task that complets when the load operation completes /// public static Task ConfigureServiceAsync(this PluginBase plugin, T service, int delayMs = 0) where T : IAsyncConfigurable { //Register async load return ObserveWork(plugin, () => service.ConfigureServiceAsync(plugin), delayMs); } /// /// /// Creates and configures a new instance of the desired type and captures the configuration /// information from the type. /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// If the type derrives the method is called once when /// the plugin is unloaded. /// /// /// The service type /// /// The a new instance configured service /// /// /// /// /// public static T CreateService(this PluginBase plugin) { if (plugin.HasConfigForType()) { IConfigScope config = plugin.GetConfigForType(); return CreateService(plugin, config); } else { return CreateService(plugin, (IConfigScope?)null); } } /// /// /// Creates and configures a new instance of the desired type, with the configuration property name /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// The service type /// /// The configuration element name to pass to the new instance /// The a new instance configured service /// /// /// /// /// public static T CreateService(this PluginBase plugin, string configName) { IConfigScope config = plugin.GetConfig(configName); return CreateService(plugin, config); } /// /// /// Creates and configures a new instance of the desired type, with the specified configuration scope /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// The service type /// /// The configuration scope to pass directly to the new instance /// The a new instance configured service /// /// /// /// /// public static T CreateService(this PluginBase plugin, IConfigScope? config) => (T)CreateService(plugin, typeof(T), config); /// /// /// Creates and configures a new instance of the desired type, with the specified configuration scope /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// If the type derrives the /// method is called once when the instance is loaded, and observed on the plugin scheduler. /// /// /// /// The service type to instantiate /// The configuration scope to pass directly to the new instance /// The a new instance configured service /// /// /// /// /// public static object CreateService(this PluginBase plugin, Type serviceType, IConfigScope? config) { ArgumentNullException.ThrowIfNull(plugin); ArgumentNullException.ThrowIfNull(serviceType); plugin.ThrowIfUnloaded(); //The requested sesrvice is not a class, so see if we can find a default implementation in assembly if (serviceType.IsAbstract || serviceType.IsInterface) { //Overwrite the service type with the default implementation serviceType = GetTypeImplFromCurrentAssembly(serviceType); } object service; try { //Determine configuration requirments if (ConfigurationExtensions.ConfigurationRequired(serviceType) && config == null) { ConfigurationExtensions.ThrowConfigNotFoundForType(serviceType); } service = InvokeServiceConstructor(serviceType, plugin, config); } catch(TargetInvocationException te) when (te.InnerException != null) { FindNestedConfigurationException(te); FindAndThrowInnerException(te); throw; } catch(Exception ex) { FindNestedConfigurationException(ex); throw; } Task? loading = null; //If the service is async configurable, configure it if (service is IAsyncConfigurable asc) { #pragma warning disable CA5394 // Do not use insecure randomness int randomDelay = Random.Shared.Next(1, 100); #pragma warning restore CA5394 // Do not use insecure randomness //Register async load loading = plugin.ConfigureServiceAsync(asc, randomDelay); } //Allow background work loading if (service is IAsyncBackgroundWork bw) { #pragma warning disable CA5394 // Do not use insecure randomness int randomDelay = Random.Shared.Next(10, 200); #pragma warning restore CA5394 // Do not use insecure randomness //If the instances supports async loading, dont start work until its loaded if (loading != null) { _ = loading.ContinueWith(t => ObserveWork(plugin, bw, randomDelay), TaskScheduler.Default); } else { _ = ObserveWork(plugin, bw, randomDelay); } } //register dispose cleanup if (service is IDisposable disp) { _ = plugin.RegisterForUnload(disp.Dispose); } return service; } /* * Attempts to find the most appropriate constructor for the service type * if found, then invokes it to create the service instance */ private static object InvokeServiceConstructor(Type serviceSType, PluginBase plugin, IConfigScope? config) { ConstructorInfo? constructor; /* * First try to load a constructor with the plugin and config scope */ if (config != null) { constructor = serviceSType.GetConstructor([typeof(PluginBase), typeof(IConfigScope)]); if(constructor is not null) { return constructor.Invoke([plugin, config]); } } //Try to get plugin only constructor constructor = serviceSType.GetConstructor([typeof(PluginBase)]); if (constructor is not null) { return constructor.Invoke([plugin]); } //Finally fall back to the empty constructor constructor = serviceSType.GetConstructor([]); if (constructor is not null) { return constructor.Invoke(null); } throw new MissingMemberException($"No constructor found for {serviceSType.Name}"); } [DoesNotReturn] internal static void FindAndThrowInnerException(Exception ex) { //Recursivley search for the innermost exception of a TIE if (ex is TargetInvocationException && ex.InnerException != null) { FindAndThrowInnerException(ex.InnerException); } else { ExceptionDispatchInfo.Throw(ex); } } internal static void FindNestedConfigurationException(Exception ex) { if(ex is ConfigurationException ce) { ExceptionDispatchInfo.Throw(ce); } //Recurse if(ex.InnerException is not null) { FindNestedConfigurationException(ex.InnerException); } //No more exceptions } private sealed class PluginLocalCache { private readonly PluginBase _plugin; private readonly Dictionary> _store; private PluginLocalCache(PluginBase plugin) { _plugin = plugin; _store = new(); //Register cleanup on unload _ = _plugin.RegisterForUnload(() => _store.Clear()); } public static PluginLocalCache Create(PluginBase plugin) => new(plugin); /* * Service code should not be executed in multiple threads, so no need to lock * * However if a service is added because it does not exist, the second call to * get service, will invoke the creation callback. Which may be "recursive" * as child dependencies required more services. */ public object GetOrCreateService(Type serviceType, Func ctor) { Lazy? lazyService; lock (_store) { lazyService = _store.Where(t => t.Key.IsAssignableTo(serviceType)) .Select(static tk => tk.Value) .FirstOrDefault(); if (lazyService is null) { lazyService = new Lazy(() => ctor(_plugin)); //add to pool _store.Add(serviceType, lazyService); } } //Return the service instance return lazyService.Value; } } } }