wip: address performance issues in plugin loading and versioning

This commit is contained in:
jtalborough
2025-02-27 17:12:57 -05:00
committed by Andrew Welker
parent 66a6612b65
commit 7591913a9c

View File

@@ -4,6 +4,9 @@ using System.Linq;
using Crestron.SimplSharp;
// using Crestron.SimplSharp.CrestronIO;
using System.Reflection;
using System.IO;
using System.Reflection.PortableExecutable;
using System.Reflection.Metadata;
using PepperDash.Core;
using PepperDash.Essentials.Core;
@@ -28,6 +31,11 @@ namespace PepperDash.Essentials
/// </summary>
static List<LoadedAssembly> LoadedPluginFolderAssemblies;
/// <summary>
/// List of plugins that were found to be incompatible with .NET 8
/// </summary>
public static List<IncompatiblePlugin> IncompatiblePlugins { get; private set; }
public static LoadedAssembly EssentialsAssembly { get; private set; }
public static LoadedAssembly PepperDashCoreAssembly { get; private set; }
@@ -47,12 +55,28 @@ namespace PepperDash.Essentials
// The temp directory where .cplz archives will be unzipped to
static string _tempDirectory => _pluginDirectory + Global.DirectorySeparator + "temp";
// Known incompatible types in .NET 8
private static readonly HashSet<string> KnownIncompatibleTypes = new HashSet<string>
{
"System.Net.ICertificatePolicy",
"System.Security.Cryptography.SHA1CryptoServiceProvider",
"System.Web.HttpUtility",
"System.Configuration.ConfigurationManager",
"System.Web.Services.Protocols.SoapHttpClientProtocol",
"System.Runtime.Remoting",
"System.EnterpriseServices",
"System.Runtime.Serialization.Formatters.Binary.BinaryFormatter",
"System.Security.SecurityManager",
"System.Security.Permissions.FileIOPermission",
"System.AppDomain.CreateDomain"
};
static PluginLoader()
{
LoadedAssemblies = new List<LoadedAssembly>();
LoadedPluginFolderAssemblies = new List<LoadedAssembly>();
EssentialsPluginAssemblies = new List<LoadedAssembly>();
IncompatiblePlugins = new List<IncompatiblePlugin>();
}
/// <summary>
@@ -62,7 +86,7 @@ namespace PepperDash.Essentials
{
Debug.LogMessage(LogEventLevel.Verbose, "Getting Assemblies loaded with Essentials");
// Get the loaded assembly filenames
var appDi = new DirectoryInfo(Global.ApplicationDirectoryPathPrefix);
var appDi = new SystemIO.DirectoryInfo(Global.ApplicationDirectoryPathPrefix);
var assemblyFiles = appDi.GetFiles("*.dll");
Debug.LogMessage(LogEventLevel.Verbose, "Found {0} Assemblies", assemblyFiles.Length);
@@ -114,7 +138,6 @@ namespace PepperDash.Essentials
}
}
public static void SetEssentialsAssembly(string name, Assembly assembly)
{
var loadedAssembly = LoadedAssemblies.FirstOrDefault(la => la.Name.Equals(name));
@@ -126,19 +149,108 @@ namespace PepperDash.Essentials
}
/// <summary>
/// Loads an assembly via Reflection and adds it to the list of loaded assemblies
/// Checks if a plugin is compatible with .NET 8 by examining its metadata
/// </summary>
/// <param name="fileName"></param>
static LoadedAssembly LoadAssembly(string filePath)
/// <param name="filePath">Path to the plugin assembly</param>
/// <returns>Tuple with compatibility result, reason if incompatible, and referenced assemblies</returns>
public static (bool IsCompatible, string Reason, List<string> References) IsPluginCompatibleWithNet8(string filePath)
{
try
{
//Debug.LogMessage(LogEventLevel.Verbose, "Attempting to load {0}", filePath);
List<string> referencedAssemblies = new List<string>();
using (SystemIO.FileStream fs = new SystemIO.FileStream(filePath, SystemIO.FileMode.Open,
SystemIO.FileAccess.Read, SystemIO.FileShare.ReadWrite))
using (PEReader peReader = new PEReader(fs))
{
if (!peReader.HasMetadata)
return (false, "Not a valid .NET assembly", referencedAssemblies);
MetadataReader metadataReader = peReader.GetMetadataReader();
// Collect assembly references
foreach (var assemblyRefHandle in metadataReader.AssemblyReferences)
{
var assemblyRef = metadataReader.GetAssemblyReference(assemblyRefHandle);
string assemblyName = metadataReader.GetString(assemblyRef.Name);
referencedAssemblies.Add(assemblyName);
}
// Check for references to known incompatible types
foreach (var typeRefHandle in metadataReader.TypeReferences)
{
var typeRef = metadataReader.GetTypeReference(typeRefHandle);
string typeNamespace = metadataReader.GetString(typeRef.Namespace);
string typeName = metadataReader.GetString(typeRef.Name);
string fullTypeName = $"{typeNamespace}.{typeName}";
if (KnownIncompatibleTypes.Contains(fullTypeName))
{
return (false, $"Uses incompatible type: {fullTypeName}", referencedAssemblies);
}
}
// Check for explicit .NET 8 compatibility attribute
bool hasNet8Attribute = false;
foreach (var customAttributeHandle in metadataReader.GetAssemblyDefinition().GetCustomAttributes())
{
var customAttribute = metadataReader.GetCustomAttribute(customAttributeHandle);
var ctorHandle = customAttribute.Constructor;
if (ctorHandle.Kind == HandleKind.MemberReference)
{
var memberRef = metadataReader.GetMemberReference((MemberReferenceHandle)ctorHandle);
var typeRef = metadataReader.GetTypeReference((TypeReferenceHandle)memberRef.Parent);
string typeName = metadataReader.GetString(typeRef.Name);
if (typeName == "Net8CompatibleAttribute" || typeName == "TargetFrameworkAttribute")
{
hasNet8Attribute = true;
break;
}
}
}
if (hasNet8Attribute)
{
return (true, null, referencedAssemblies);
}
// If we can't determine incompatibility, assume it's compatible
return (true, null, referencedAssemblies);
}
}
catch (Exception ex)
{
return (false, $"Error analyzing assembly: {ex.Message}", new List<string>());
}
}
/// <summary>
/// Loads an assembly via Reflection and adds it to the list of loaded assemblies
/// </summary>
/// <param name="filePath">Path to the assembly file</param>
/// <param name="requestedBy">Name of the plugin requesting this assembly (null for direct loads)</param>
static LoadedAssembly LoadAssembly(string filePath, string requestedBy = null)
{
try
{
// Check .NET 8 compatibility before loading
var (isCompatible, reason, references) = IsPluginCompatibleWithNet8(filePath);
if (!isCompatible)
{
string fileName = CrestronIO.Path.GetFileName(filePath);
Debug.LogMessage(LogEventLevel.Warning, "Assembly '{0}' is not compatible with .NET 8: {1}", fileName, reason);
var incompatiblePlugin = new IncompatiblePlugin(fileName, reason, requestedBy);
IncompatiblePlugins.Add(incompatiblePlugin);
return null;
}
var assembly = Assembly.LoadFrom(filePath);
if (assembly != null)
{
var assyVersion = GetAssemblyVersion(assembly);
var loadedAssembly = new LoadedAssembly(assembly.GetName().Name, assyVersion, assembly);
LoadedAssemblies.Add(loadedAssembly);
Debug.LogMessage(LogEventLevel.Information, "Loaded assembly '{0}', version {1}", loadedAssembly.Name, loadedAssembly.Version);
@@ -148,14 +260,47 @@ namespace PepperDash.Essentials
{
Debug.LogMessage(LogEventLevel.Information, "Unable to load assembly: '{0}'", filePath);
}
return null;
} catch(Exception ex)
{
Debug.LogMessage(ex, "Error loading assembly from {path}", null, filePath);
return null;
}
catch(SystemIO.FileLoadException ex) when (ex.Message.Contains("Assembly with same name is already loaded"))
{
// Get the assembly name from the file path
string assemblyName = CrestronIO.Path.GetFileNameWithoutExtension(filePath);
// Try to find the already loaded assembly
var existingAssembly = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => a.GetName().Name.Equals(assemblyName, StringComparison.OrdinalIgnoreCase));
if (existingAssembly != null)
{
Debug.LogMessage(LogEventLevel.Information, "Assembly '{0}' is already loaded, using existing instance", assemblyName);
var assyVersion = GetAssemblyVersion(existingAssembly);
var loadedAssembly = new LoadedAssembly(existingAssembly.GetName().Name, assyVersion, existingAssembly);
LoadedAssemblies.Add(loadedAssembly);
return loadedAssembly;
}
Debug.LogMessage(LogEventLevel.Warning, "Assembly with same name already loaded but couldn't find it: {0}", filePath);
return null;
}
catch(Exception ex)
{
string fileName = CrestronIO.Path.GetFileName(filePath);
// Check if this might be a .NET Framework compatibility issue
if (ex.Message.Contains("Could not load type") ||
ex.Message.Contains("Unable to load one or more of the requested types"))
{
Debug.LogMessage(LogEventLevel.Error, "Error loading assembly {0}: Likely .NET 8 compatibility issue: {1}",
fileName, ex.Message);
IncompatiblePlugins.Add(new IncompatiblePlugin(fileName, ex.Message, requestedBy));
}
else
{
Debug.LogMessage(ex, "Error loading assembly from {path}", null, filePath);
}
return null;
}
}
/// <summary>
@@ -217,12 +362,25 @@ namespace PepperDash.Essentials
CrestronConsole.ConsoleCommandResponse("{0} Version: {1}" + CrestronEnvironment.NewLine, assembly.Name, assembly.Version);
}
//CrestronConsole.ConsoleCommandResponse("Loaded Assemblies:" + CrestronEnvironment.NewLine);
//foreach (var assembly in LoadedAssemblies)
//{
// CrestronConsole.ConsoleCommandResponse("{0} Version: {1}" + CrestronEnvironment.NewLine, assembly.Name, assembly.Version);
//}
if (IncompatiblePlugins.Count > 0)
{
CrestronConsole.ConsoleCommandResponse("Incompatible Plugins:" + CrestronEnvironment.NewLine);
foreach (var plugin in IncompatiblePlugins)
{
if (plugin.TriggeredBy != "Direct load")
{
CrestronConsole.ConsoleCommandResponse("{0}: {1} (Required by: {2})" + CrestronEnvironment.NewLine,
plugin.Name, plugin.Reason, plugin.TriggeredBy);
}
else
{
CrestronConsole.ConsoleCommandResponse("{0}: {1}" + CrestronEnvironment.NewLine,
plugin.Name, plugin.Reason);
}
}
}
}
/// <summary>
/// Moves any .dll assemblies not already loaded from the plugins folder to loadedPlugins folder
/// </summary>
@@ -230,14 +388,14 @@ namespace PepperDash.Essentials
{
Debug.LogMessage(LogEventLevel.Information, "Looking for .dll assemblies from plugins folder...");
var pluginDi = new DirectoryInfo(_pluginDirectory);
var pluginDi = new SystemIO.DirectoryInfo(_pluginDirectory);
var pluginFiles = pluginDi.GetFiles("*.dll");
if (pluginFiles.Length > 0)
{
if (!Directory.Exists(_loadedPluginsDirectoryPath))
if (!SystemIO.Directory.Exists(_loadedPluginsDirectoryPath))
{
Directory.CreateDirectory(_loadedPluginsDirectoryPath);
SystemIO.Directory.CreateDirectory(_loadedPluginsDirectoryPath);
}
}
@@ -254,14 +412,14 @@ namespace PepperDash.Essentials
filePath = _loadedPluginsDirectoryPath + Global.DirectorySeparator + pluginFile.Name;
// Check if there is a previous file in the loadedPlugins directory and delete
if (File.Exists(filePath))
if (SystemIO.File.Exists(filePath))
{
Debug.LogMessage(LogEventLevel.Information, "Found existing file in loadedPlugins: {0} Deleting and moving new file to replace it", filePath);
File.Delete(filePath);
SystemIO.File.Delete(filePath);
}
// Move the file
File.Move(pluginFile.FullName, filePath);
SystemIO.File.Move(pluginFile.FullName, filePath);
Debug.LogMessage(LogEventLevel.Verbose, "Moved {0} to {1}", pluginFile.FullName, filePath);
}
else
@@ -284,9 +442,9 @@ namespace PepperDash.Essentials
/// </summary>
static void UnzipAndMoveCplzArchives()
{
Debug.LogMessage(LogEventLevel.Information, "Looking for .cplz archives from user folder...");
//var di = new DirectoryInfo(_pluginDirectory);
//var zFiles = di.GetFiles("*.cplz");
Debug.LogMessage(LogEventLevel.Information, "Looking for .cplz archives from plugins folder...");
var di = new SystemIO.DirectoryInfo(_pluginDirectory);
var zFiles = di.GetFiles("*.cplz");
//// Find cplz files at the root of the user folder. Makes development/testing easier for VC-4, and helps with mistakes by end users
@@ -300,16 +458,16 @@ namespace PepperDash.Essentials
if (cplzFiles.Length > 0)
{
if (!Directory.Exists(_loadedPluginsDirectoryPath))
if (!SystemIO.Directory.Exists(_loadedPluginsDirectoryPath))
{
Directory.CreateDirectory(_loadedPluginsDirectoryPath);
SystemIO.Directory.CreateDirectory(_loadedPluginsDirectoryPath);
}
}
foreach (var zfi in cplzFiles)
{
Directory.CreateDirectory(_tempDirectory);
var tempDi = new DirectoryInfo(_tempDirectory);
SystemIO.Directory.CreateDirectory(_tempDirectory);
var tempDi = new SystemIO.DirectoryInfo(_tempDirectory);
Debug.LogMessage(LogEventLevel.Information, "Found cplz: {0}. Unzipping into temp plugins directory", zfi.FullName);
var result = CrestronZIP.Unzip(zfi.FullName, tempDi.FullName);
@@ -327,14 +485,14 @@ namespace PepperDash.Essentials
filePath = _loadedPluginsDirectoryPath + Global.DirectorySeparator + tempFile.Name;
// Check if there is a previous file in the loadedPlugins directory and delete
if (File.Exists(filePath))
if (SystemIO.File.Exists(filePath))
{
Debug.LogMessage(LogEventLevel.Information, "Found existing file in loadedPlugins: {0} Deleting and moving new file to replace it", filePath);
File.Delete(filePath);
SystemIO.File.Delete(filePath);
}
// Move the file
File.Move(tempFile.FullName, filePath);
SystemIO.File.Move(tempFile.FullName, filePath);
Debug.LogMessage(LogEventLevel.Verbose, "Moved {0} to {1}", tempFile.FullName, filePath);
}
else
@@ -350,7 +508,7 @@ namespace PepperDash.Essentials
}
// Delete the .cplz and the temp directory
Directory.Delete(_tempDirectory, true);
SystemIO.Directory.Delete(_tempDirectory, true);
zfi.Delete();
}
@@ -363,16 +521,40 @@ namespace PepperDash.Essentials
static void LoadPluginAssemblies()
{
Debug.LogMessage(LogEventLevel.Information, "Loading assemblies from loadedPlugins folder...");
var pluginDi = new DirectoryInfo(_loadedPluginsDirectoryPath);
var pluginDi = new CrestronIO.DirectoryInfo(_loadedPluginsDirectoryPath);
var pluginFiles = pluginDi.GetFiles("*.dll");
Debug.LogMessage(LogEventLevel.Verbose, "Found {0} plugin assemblies to load", pluginFiles.Length);
// First, check compatibility of all assemblies before loading any
var assemblyCompatibility = new Dictionary<string, (bool IsCompatible, string Reason, List<string> References)>();
foreach (var pluginFile in pluginFiles)
{
var loadedAssembly = LoadAssembly(pluginFile.FullName);
LoadedPluginFolderAssemblies.Add(loadedAssembly);
string fileName = pluginFile.Name;
assemblyCompatibility[fileName] = IsPluginCompatibleWithNet8(pluginFile.FullName);
}
// Now load compatible assemblies and track incompatible ones
foreach (var pluginFile in pluginFiles)
{
string fileName = pluginFile.Name;
var (isCompatible, reason, references) = assemblyCompatibility[fileName];
if (!isCompatible)
{
Debug.LogMessage(LogEventLevel.Warning, "Assembly '{0}' is not compatible with .NET 8: {1}", fileName, reason);
IncompatiblePlugins.Add(new IncompatiblePlugin(fileName, reason, null));
continue;
}
// Try to load the assembly
var loadedAssembly = LoadAssembly(pluginFile.FullName, null);
if (loadedAssembly != null)
{
LoadedPluginFolderAssemblies.Add(loadedAssembly);
}
}
Debug.LogMessage(LogEventLevel.Information, "All Plugins Loaded.");
@@ -384,8 +566,13 @@ namespace PepperDash.Essentials
static void LoadCustomPluginTypes()
{
Debug.LogMessage(LogEventLevel.Information, "Loading Custom Plugin Types...");
foreach (var loadedAssembly in LoadedPluginFolderAssemblies)
{
// Skip if assembly is null (can happen if we had loading issues)
if (loadedAssembly == null || loadedAssembly.Assembly == null)
continue;
// iteratate this assembly's classes, looking for "LoadPlugin()" methods
try
{
@@ -396,11 +583,49 @@ namespace PepperDash.Essentials
types = assy.GetTypes();
Debug.LogMessage(LogEventLevel.Debug, $"Got types for assembly {assy.GetName().Name}");
}
catch (ReflectionTypeLoadException e)
{
Debug.LogMessage(LogEventLevel.Error, "Unable to get types for assembly {0}: {1}",
loadedAssembly.Name, e.Message);
// Check if any of the loader exceptions are due to missing assemblies
foreach (var loaderEx in e.LoaderExceptions)
{
if (loaderEx is SystemIO.FileNotFoundException fileNotFoundEx)
{
string missingAssembly = fileNotFoundEx.FileName;
if (!string.IsNullOrEmpty(missingAssembly))
{
Debug.LogMessage(LogEventLevel.Warning, "Assembly {0} requires missing dependency: {1}",
loadedAssembly.Name, missingAssembly);
// Add to incompatible plugins with dependency information
IncompatiblePlugins.Add(new IncompatiblePlugin(
CrestronIO.Path.GetFileName(missingAssembly),
$"Missing dependency required by {loadedAssembly.Name}",
loadedAssembly.Name));
}
}
}
Debug.LogMessage(LogEventLevel.Verbose, e.StackTrace);
continue;
}
catch (TypeLoadException e)
{
Debug.LogMessage(LogEventLevel.Error, "Unable to get types for assembly {0}: {1}",
loadedAssembly.Name, e.Message);
Debug.LogMessage(LogEventLevel.Verbose, e.StackTrace);
// Add to incompatible plugins if this is likely a .NET 8 compatibility issue
if (e.Message.Contains("Could not load type") ||
e.Message.Contains("Unable to load one or more of the requested types"))
{
IncompatiblePlugins.Add(new IncompatiblePlugin(loadedAssembly.Name,
$"Type loading error: {e.Message}",
null));
}
continue;
}
@@ -425,22 +650,63 @@ namespace PepperDash.Essentials
loadedAssembly.Name, e.Message, type.Name);
continue;
}
}
}
catch (Exception e)
{
Debug.LogMessage(LogEventLevel.Information, "Error Loading assembly {0}: {1}",
loadedAssembly.Name, e.Message);
loadedAssembly.Name, e.Message);
Debug.LogMessage(LogEventLevel.Verbose, "{0}", e.StackTrace);
// Add to incompatible plugins if this is likely a .NET 8 compatibility issue
if (e.Message.Contains("Could not load type") ||
e.Message.Contains("Unable to load one or more of the requested types"))
{
IncompatiblePlugins.Add(new IncompatiblePlugin(loadedAssembly.Name,
$"Assembly loading error: {e.Message}",
null));
}
continue;
}
}
// Update incompatible plugins with dependency information
UpdateIncompatiblePluginDependencies();
// plugin dll will be loaded. Any classes in plugin should have a static constructor
// that registers that class with the Core.DeviceFactory
Debug.LogMessage(LogEventLevel.Information, "Done Loading Custom Plugin Types.");
}
/// <summary>
/// Updates incompatible plugins with information about which plugins depend on them
/// </summary>
private static void UpdateIncompatiblePluginDependencies(Dictionary<string, List<string>> pluginDependencies)
{
// For each incompatible plugin
foreach (var incompatiblePlugin in IncompatiblePlugins)
{
// If it already has a requestedBy, skip it
if (incompatiblePlugin.TriggeredBy != "Direct load")
continue;
// Find plugins that depend on this incompatible plugin
foreach (var plugin in pluginDependencies)
{
string pluginName = plugin.Key;
List<string> dependencies = plugin.Value;
// If this plugin depends on the incompatible plugin
if (dependencies.Contains(incompatiblePlugin.Name) ||
dependencies.Any(d => d.StartsWith(incompatiblePlugin.Name + ",")))
{
incompatiblePlugin.UpdateTriggeringPlugin(pluginName);
break;
}
}
}
}
/// <summary>
/// Loads a
/// </summary>
@@ -517,7 +783,6 @@ namespace PepperDash.Essentials
Debug.LogMessage(LogEventLevel.Information, "Loading legacy plugin: {0}", loadedAssembly.Name);
loadPlugin.Invoke(null, null);
}
/// <summary>
@@ -527,7 +792,7 @@ namespace PepperDash.Essentials
{
Debug.LogMessage(LogEventLevel.Information, "Attempting to Load Plugins from {_pluginDirectory}", _pluginDirectory);
if (Directory.Exists(_pluginDirectory))
if (SystemIO.Directory.Exists(_pluginDirectory))
{
Debug.LogMessage(LogEventLevel.Information, "Plugins directory found, checking for plugins");
@@ -537,7 +802,7 @@ namespace PepperDash.Essentials
// Deal with any .cplz files
UnzipAndMoveCplzArchives();
if (Directory.Exists(_loadedPluginsDirectoryPath))
if (SystemIO.Directory.Exists(_loadedPluginsDirectoryPath))
{
// Load the assemblies from the loadedPlugins folder into the AppDomain
LoadPluginAssemblies();
@@ -545,9 +810,27 @@ namespace PepperDash.Essentials
// Load the types from any custom plugin assemblies
LoadCustomPluginTypes();
}
// Report on incompatible plugins
if (IncompatiblePlugins.Count > 0)
{
Debug.LogMessage(LogEventLevel.Warning, "Found {0} incompatible plugins:", IncompatiblePlugins.Count);
foreach (var plugin in IncompatiblePlugins)
{
if (plugin.TriggeredBy != "Direct load")
{
Debug.LogMessage(LogEventLevel.Warning, " - {0}: {1} (Required by: {2})",
plugin.Name, plugin.Reason, plugin.TriggeredBy);
}
else
{
Debug.LogMessage(LogEventLevel.Warning, " - {0}: {1}",
plugin.Name, plugin.Reason);
}
}
}
}
}
}
/// <summary>
@@ -574,4 +857,52 @@ namespace PepperDash.Essentials
Assembly = assembly;
}
}
/// <summary>
/// Represents a plugin that was found to be incompatible with .NET 8
/// </summary>
public class IncompatiblePlugin
{
[JsonProperty("name")]
public string Name { get; private set; }
[JsonProperty("reason")]
public string Reason { get; private set; }
[JsonProperty("triggeredBy")]
public string TriggeredBy { get; private set; }
public IncompatiblePlugin(string name, string reason, string triggeredBy = null)
{
Name = name;
Reason = reason;
TriggeredBy = triggeredBy ?? "Direct load";
}
/// <summary>
/// Updates the plugin that triggered this incompatibility
/// </summary>
/// <param name="triggeringPlugin">Name of the plugin that requires this incompatible plugin</param>
public void UpdateTriggeringPlugin(string triggeringPlugin)
{
if (!string.IsNullOrEmpty(triggeringPlugin))
{
TriggeredBy = triggeringPlugin;
}
}
}
/// <summary>
/// Attribute to explicitly mark a plugin as .NET 8 compatible
/// </summary>
[AttributeUsage(AttributeTargets.Assembly)]
public class Net8CompatibleAttribute : Attribute
{
public bool IsCompatible { get; }
public Net8CompatibleAttribute(bool isCompatible = true)
{
IsCompatible = isCompatible;
}
}
}