using AcDream.Plugin.Abstractions; namespace AcDream.Core.Plugins; public enum PluginSessionStatusKind { Loaded, Failed, } /// /// Final startup outcome for one configured plugin id. Hosts translate these /// outcomes into their own diagnostics and the Campaign LA status stream. /// public readonly record struct PluginSessionStatus( string Plugin, PluginSessionStatusKind Kind, string? Error = null); /// /// One host/session-scoped plugin lifetime. Discovery, allow-listing, /// initialize/enable, failure isolation, reverse-order disable, and collectible /// load-context release are shared by graphical and no-window hosts so their /// configured plugin-set semantics cannot drift. /// public sealed class PluginSession : IDisposable { private readonly IPluginHost _host; private readonly Action? _report; private readonly List _loaded = []; private readonly List _releasedContexts = []; private bool _started; private bool _disposed; public PluginSession( IPluginHost host, Action? report = null) { _host = host ?? throw new ArgumentNullException(nameof(host)); _report = report; } public int LoadedCount => _loaded.Count; public IReadOnlyList LoadedPluginIds => _loaded.Select(static active => active.Loaded.Manifest.Id).ToArray(); /// /// Discovers and starts the configured set exactly once. A /// allow-list loads every discovered id; an explicit /// empty list loads none. Matching and duplicate-id handling are /// ordinal-ignore-case on every operating system because plugin ids are /// logical identifiers, not paths. /// public void Start( IEnumerable pluginRoots, IReadOnlyList? allowList) { ArgumentNullException.ThrowIfNull(pluginRoots); ObjectDisposedException.ThrowIf(_disposed, this); if (_started) throw new InvalidOperationException("The plugin session has already started."); _started = true; string[] roots = DistinctRoots(pluginRoots); string[]? requested = allowList is null ? null : allowList .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); if (requested is { Length: 0 }) return; var candidates = new Dictionary>( StringComparer.OrdinalIgnoreCase); var errors = new Dictionary>( StringComparer.OrdinalIgnoreCase); var discoveredOrder = new List(); HashSet? requestedSet = requested is null ? null : new HashSet(requested, StringComparer.OrdinalIgnoreCase); foreach (string root in roots) { IReadOnlyList results; try { results = PluginDiscovery.Scan(root); } catch (Exception error) when (IsDiscoveryFailure(error)) { SafeLog( static (log, message, exception) => log.Error(message, exception), $"plugin discovery failed for root '{root}'", error); continue; } foreach (PluginDiscoveryResult result in results) { if (!result.Success) { string directoryId = Path.GetFileName( Path.TrimEndingDirectorySeparator(result.PluginDirectory)); if (string.IsNullOrWhiteSpace(directoryId) || (requestedSet is not null && !requestedSet.Contains(directoryId))) { continue; } AddOrdered(discoveredOrder, directoryId); AddError( errors, directoryId, result.Error ?? new InvalidOperationException( "plugin discovery failed")); continue; } string id = result.Manifest!.Id; if (requestedSet is not null && !requestedSet.Contains(id)) continue; AddOrdered(discoveredOrder, id); if (!candidates.TryGetValue(id, out List? list)) { list = []; candidates.Add(id, list); } list.Add(result); } } IEnumerable loadOrder = requested is null ? discoveredOrder : requested; foreach (string id in loadOrder) LoadOne(id, candidates, errors); } /// /// Test/diagnostic observation of the exact collectible contexts currently /// owned by this session. The returned weak references do not delay unload. /// public IReadOnlyList CaptureLoadContextWeakReferences() => [ .. _releasedContexts, .. _loaded.Select(static active => new WeakReference(active.Loaded.LoadContext!)), ]; public void Dispose() { if (_disposed) return; _disposed = true; for (int index = _loaded.Count - 1; index >= 0; index--) { ActivePlugin active = _loaded[index]; LoadedPlugin loaded = active.Loaded; try { loaded.Plugin!.Disable(); } catch (Exception error) { SafeLog( static (log, message, exception) => log.Error(message, exception), $"plugin disable failed: {loaded.Manifest.Id}", error); } // Host-owned registrations are released even when Disable throws. // This must precede ALC unload so no UI binding or event delegate // can keep the plugin assembly reachable. active.Scope.Dispose(); try { loaded.LoadContext!.Unload(); } catch (Exception error) { SafeLog( static (log, message, exception) => log.Error(message, exception), $"plugin unload failed: {loaded.Manifest.Id}", error); } } // Drop both plugin instances and AssemblyLoadContext references. The // CLR completes collectible unload after no plugin-owned object remains // reachable and a normal GC cycle observes the contexts. _loaded.Clear(); } private void LoadOne( string id, IReadOnlyDictionary> candidates, Dictionary> errors) { if (candidates.TryGetValue(id, out List? available)) { foreach (PluginDiscoveryResult candidate in available) { var scope = new ScopedPluginHost(_host); LoadedPlugin loaded = PluginLoader.Load( candidate.PluginDirectory, candidate.Manifest!, scope); if (!loaded.Success) { // Initialize can register callbacks before it fails. The // registration transaction closes before plugin cleanup // and, critically, before any ALC Unloading notification. scope.Dispose(); ReleaseFailedLoad(loaded); AddError( errors, id, loaded.Error ?? new InvalidOperationException( "plugin load failed")); continue; } try { loaded.Plugin!.Enable(); _loaded.Add(new ActivePlugin(loaded, scope)); SafeLog( static (log, message, _) => log.Info(message), $"plugin loaded: {loaded.Manifest.Id} " + $"({loaded.Manifest.DisplayName})", null); Report(new PluginSessionStatus( loaded.Manifest.Id, PluginSessionStatusKind.Loaded)); return; } catch (Exception error) { AddError(errors, id, error); ReleaseFailedEnable(loaded, scope); } } } if (!errors.TryGetValue(id, out List? failures) || failures.Count == 0) { failures = [ new FileNotFoundException( $"plugin '{id}' was not found in the configured plugin roots."), ]; } string errorText = string.Join( " | ", failures.Select(Describe)); Report(new PluginSessionStatus( id, PluginSessionStatusKind.Failed, errorText)); SafeLog( static (log, message, _) => log.Warn(message), $"plugin failed: {id}: {errorText}", null); } private void ReleaseFailedEnable( LoadedPlugin loaded, ScopedPluginHost scope) { try { loaded.Plugin!.Disable(); } catch (Exception error) { SafeLog( static (log, message, exception) => log.Error(message, exception), $"plugin cleanup after enable failure failed: {loaded.Manifest.Id}", error); } scope.Dispose(); _releasedContexts.Add(new WeakReference(loaded.LoadContext!)); try { loaded.LoadContext!.Unload(); } catch (Exception error) { SafeLog( static (log, message, exception) => log.Error(message, exception), $"plugin unload after enable failure failed: {loaded.Manifest.Id}", error); } } private void ReleaseFailedLoad(LoadedPlugin loaded) { if (loaded.Plugin is not null) { try { loaded.Plugin.Disable(); } catch (Exception error) { SafeLog( static (log, message, exception) => log.Error(message, exception), $"plugin cleanup after initialize failure failed: {loaded.Manifest.Id}", error); } } if (loaded.LoadContext is null) return; _releasedContexts.Add(new WeakReference(loaded.LoadContext)); try { loaded.LoadContext.Unload(); } catch (Exception error) { SafeLog( static (log, message, exception) => log.Error(message, exception), $"plugin unload after load failure failed: {loaded.Manifest.Id}", error); } } private void Report(PluginSessionStatus status) { if (_report is null) return; try { _report(status); } catch (Exception error) { SafeLog( static (log, message, exception) => log.Error(message, exception), $"plugin status observer failed for {status.Plugin}", error); } } private void SafeLog( Action write, string message, Exception? error) { try { write(_host.Log, message, error); } catch { } } private static string[] DistinctRoots(IEnumerable roots) { StringComparer comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; return roots .Where(static root => !string.IsNullOrWhiteSpace(root)) .Select(Path.GetFullPath) .Distinct(comparer) .ToArray(); } private static void AddOrdered(List ordered, string id) { if (!ordered.Contains(id, StringComparer.OrdinalIgnoreCase)) ordered.Add(id); } private static void AddError( Dictionary> errors, string id, Exception error) { if (!errors.TryGetValue(id, out List? list)) { list = []; errors.Add(id, list); } list.Add(error); } private static string Describe(Exception error) { Exception root = error.GetBaseException(); return string.IsNullOrWhiteSpace(root.Message) ? root.GetType().Name : root.Message; } private static bool IsDiscoveryFailure(Exception error) => error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or System.Security.SecurityException; private sealed record ActivePlugin( LoadedPlugin Loaded, ScopedPluginHost Scope); }