421 lines
14 KiB
C#
421 lines
14 KiB
C#
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Core.Plugins;
|
|
|
|
public enum PluginSessionStatusKind
|
|
{
|
|
Loaded,
|
|
Failed,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Final startup outcome for one configured plugin id. Hosts translate these
|
|
/// outcomes into their own diagnostics and the Campaign LA status stream.
|
|
/// </summary>
|
|
public readonly record struct PluginSessionStatus(
|
|
string Plugin,
|
|
PluginSessionStatusKind Kind,
|
|
string? Error = null);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class PluginSession : IDisposable
|
|
{
|
|
private readonly IPluginHost _host;
|
|
private readonly Action<PluginSessionStatus>? _report;
|
|
private readonly List<ActivePlugin> _loaded = [];
|
|
private readonly List<WeakReference> _releasedContexts = [];
|
|
private bool _started;
|
|
private bool _disposed;
|
|
|
|
public PluginSession(
|
|
IPluginHost host,
|
|
Action<PluginSessionStatus>? report = null)
|
|
{
|
|
_host = host ?? throw new ArgumentNullException(nameof(host));
|
|
_report = report;
|
|
}
|
|
|
|
public int LoadedCount => _loaded.Count;
|
|
|
|
public IReadOnlyList<string> LoadedPluginIds =>
|
|
_loaded.Select(static active => active.Loaded.Manifest.Id).ToArray();
|
|
|
|
/// <summary>
|
|
/// Discovers and starts the configured set exactly once. A
|
|
/// <see langword="null"/> 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.
|
|
/// </summary>
|
|
public void Start(
|
|
IEnumerable<string> pluginRoots,
|
|
IReadOnlyList<string>? 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<string, List<PluginDiscoveryResult>>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
var errors = new Dictionary<string, List<Exception>>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
var discoveredOrder = new List<string>();
|
|
HashSet<string>? requestedSet = requested is null
|
|
? null
|
|
: new HashSet<string>(requested, StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (string root in roots)
|
|
{
|
|
IReadOnlyList<PluginDiscoveryResult> 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<PluginDiscoveryResult>? list))
|
|
{
|
|
list = [];
|
|
candidates.Add(id, list);
|
|
}
|
|
list.Add(result);
|
|
}
|
|
}
|
|
|
|
IEnumerable<string> loadOrder = requested is null
|
|
? discoveredOrder
|
|
: requested;
|
|
foreach (string id in loadOrder)
|
|
LoadOne(id, candidates, errors);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test/diagnostic observation of the exact collectible contexts currently
|
|
/// owned by this session. The returned weak references do not delay unload.
|
|
/// </summary>
|
|
public IReadOnlyList<WeakReference> 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<string, List<PluginDiscoveryResult>> candidates,
|
|
Dictionary<string, List<Exception>> errors)
|
|
{
|
|
if (candidates.TryGetValue(id, out List<PluginDiscoveryResult>? 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<Exception>? 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<IPluginLogger, string, Exception?> write,
|
|
string message,
|
|
Exception? error)
|
|
{
|
|
try { write(_host.Log, message, error); }
|
|
catch { }
|
|
}
|
|
|
|
private static string[] DistinctRoots(IEnumerable<string> 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<string> ordered, string id)
|
|
{
|
|
if (!ordered.Contains(id, StringComparer.OrdinalIgnoreCase))
|
|
ordered.Add(id);
|
|
}
|
|
|
|
private static void AddError(
|
|
Dictionary<string, List<Exception>> errors,
|
|
string id,
|
|
Exception error)
|
|
{
|
|
if (!errors.TryGetValue(id, out List<Exception>? 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);
|
|
}
|