fix(plugins): close LA5 host lifecycle review
This commit is contained in:
parent
95f4be94db
commit
fbe9c8a288
25 changed files with 1043 additions and 120 deletions
|
|
@ -4,11 +4,7 @@ using AcDream.Runtime.Session;
|
||||||
namespace AcDream.App.Composition;
|
namespace AcDream.App.Composition;
|
||||||
|
|
||||||
internal sealed record SessionStartDependencies(
|
internal sealed record SessionStartDependencies(
|
||||||
Action<string> Log,
|
Action<string> Log);
|
||||||
/// <summary>Campaign LA slice LA1: no-op when no statusFile was
|
|
||||||
/// configured.</summary>
|
|
||||||
SessionStatusWriter StatusWriter,
|
|
||||||
string SessionId);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Terminal startup phase. Every callback, command target, and frame root is
|
/// Terminal startup phase. Every callback, command target, and frame root is
|
||||||
|
|
@ -26,9 +22,6 @@ internal sealed class SessionStartCompositionPhase
|
||||||
public void Start(FrameRootResult frame)
|
public void Start(FrameRootResult frame)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(frame);
|
ArgumentNullException.ThrowIfNull(frame);
|
||||||
// Campaign LA slice LA1: "started" = session host start — the
|
|
||||||
// earliest point the graphical host actually attempts to connect.
|
|
||||||
_dependencies.StatusWriter.Started(_dependencies.SessionId);
|
|
||||||
RuntimeSessionStartResult result =
|
RuntimeSessionStartResult result =
|
||||||
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
|
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
|
||||||
Report(result, _dependencies.Log);
|
Report(result, _dependencies.Log);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using AcDream.App.UI;
|
||||||
using AcDream.Plugin.Abstractions;
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
namespace AcDream.App.Plugins;
|
namespace AcDream.App.Plugins;
|
||||||
|
|
@ -8,20 +9,119 @@ namespace AcDream.App.Plugins;
|
||||||
/// Program.cs before the GL window opens) until GameWindow drains them into the
|
/// Program.cs before the GL window opens) until GameWindow drains them into the
|
||||||
/// UiHost tree after construction.
|
/// UiHost tree after construction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BufferedUiRegistry : IUiRegistry
|
public sealed class BufferedUiRegistry : IScopedUiRegistry
|
||||||
{
|
{
|
||||||
public readonly record struct Pending(string MarkupPath, object Binding);
|
public readonly record struct Pending(string MarkupPath, object Binding)
|
||||||
|
{
|
||||||
|
internal long RegistrationId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
private readonly List<Pending> _pending = new();
|
private sealed class Registration(string markupPath, object binding)
|
||||||
|
{
|
||||||
|
internal string MarkupPath { get; } = markupPath;
|
||||||
|
internal object Binding { get; } = binding;
|
||||||
|
internal bool Drained { get; set; }
|
||||||
|
internal UiRoot? Root { get; set; }
|
||||||
|
internal UiElement? Element { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<long, Registration> _registrations = [];
|
||||||
|
private long _nextRegistrationId;
|
||||||
|
|
||||||
public void AddMarkupPanel(string markupPath, object binding)
|
public void AddMarkupPanel(string markupPath, object binding)
|
||||||
=> _pending.Add(new Pending(markupPath, binding));
|
=> _ = RegisterMarkupPanel(markupPath, binding);
|
||||||
|
|
||||||
/// <summary>Return + clear all buffered registrations.</summary>
|
public IDisposable RegisterMarkupPanel(string markupPath, object binding)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(markupPath);
|
||||||
|
ArgumentNullException.ThrowIfNull(binding);
|
||||||
|
long id;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
id = checked(++_nextRegistrationId);
|
||||||
|
_registrations.Add(id, new Registration(markupPath, binding));
|
||||||
|
}
|
||||||
|
return new RegistrationToken(this, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns each not-yet-drained active registration once.</summary>
|
||||||
public IReadOnlyList<Pending> Drain()
|
public IReadOnlyList<Pending> Drain()
|
||||||
{
|
{
|
||||||
var copy = _pending.ToArray();
|
lock (_gate)
|
||||||
_pending.Clear();
|
{
|
||||||
return copy;
|
var pending = new List<Pending>(_registrations.Count);
|
||||||
|
foreach ((long id, Registration registration) in _registrations)
|
||||||
|
{
|
||||||
|
if (registration.Drained)
|
||||||
|
continue;
|
||||||
|
registration.Drained = true;
|
||||||
|
pending.Add(new Pending(
|
||||||
|
registration.MarkupPath,
|
||||||
|
registration.Binding)
|
||||||
|
{
|
||||||
|
RegistrationId = id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void CompleteMount(Pending pending, UiRoot root, UiElement element)
|
||||||
|
{
|
||||||
|
bool stillRegistered;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
stillRegistered = _registrations.TryGetValue(
|
||||||
|
pending.RegistrationId,
|
||||||
|
out Registration? registration);
|
||||||
|
if (stillRegistered)
|
||||||
|
{
|
||||||
|
registration!.Root = root;
|
||||||
|
registration.Element = element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plugin can fail/disable while markup is being built. Never leave
|
||||||
|
// the just-built child mounted if its host-owned token was rolled back.
|
||||||
|
if (!stillRegistered)
|
||||||
|
root.RemoveChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void FailMount(Pending pending) => Remove(pending.RegistrationId);
|
||||||
|
|
||||||
|
internal int RegistrationCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _registrations.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Remove(long id)
|
||||||
|
{
|
||||||
|
UiRoot? root;
|
||||||
|
UiElement? element;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_registrations.Remove(id, out Registration? registration))
|
||||||
|
return;
|
||||||
|
root = registration.Root;
|
||||||
|
element = registration.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root is not null && element is not null)
|
||||||
|
root.RemoveChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RegistrationToken(
|
||||||
|
BufferedUiRegistry owner,
|
||||||
|
long registrationId) : IDisposable
|
||||||
|
{
|
||||||
|
private BufferedUiRegistry? _owner = owner;
|
||||||
|
|
||||||
|
public void Dispose() =>
|
||||||
|
Interlocked.Exchange(ref _owner, null)?.Remove(registrationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,24 @@ namespace AcDream.App.Plugins;
|
||||||
internal sealed class GraphicalPluginSession : IDisposable
|
internal sealed class GraphicalPluginSession : IDisposable
|
||||||
{
|
{
|
||||||
private readonly PluginSession _plugins;
|
private readonly PluginSession _plugins;
|
||||||
|
private readonly string[] _roots;
|
||||||
|
private readonly IReadOnlyList<string>? _allowList;
|
||||||
|
private readonly string _sessionId;
|
||||||
|
private readonly SessionStatusWriter _statusWriter;
|
||||||
|
private bool _started;
|
||||||
|
|
||||||
private GraphicalPluginSession(PluginSession plugins)
|
private GraphicalPluginSession(
|
||||||
|
PluginSession plugins,
|
||||||
|
string[] roots,
|
||||||
|
IReadOnlyList<string>? allowList,
|
||||||
|
string sessionId,
|
||||||
|
SessionStatusWriter statusWriter)
|
||||||
{
|
{
|
||||||
_plugins = plugins;
|
_plugins = plugins;
|
||||||
|
_roots = roots;
|
||||||
|
_allowList = allowList;
|
||||||
|
_sessionId = sessionId;
|
||||||
|
_statusWriter = statusWriter;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal int LoadedCount => _plugins.LoadedCount;
|
internal int LoadedCount => _plugins.LoadedCount;
|
||||||
|
|
@ -25,7 +39,7 @@ internal sealed class GraphicalPluginSession : IDisposable
|
||||||
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||||
_plugins.CaptureLoadContextWeakReferences();
|
_plugins.CaptureLoadContextWeakReferences();
|
||||||
|
|
||||||
internal static GraphicalPluginSession Start(
|
internal static GraphicalPluginSession Create(
|
||||||
ApplicationPathSet paths,
|
ApplicationPathSet paths,
|
||||||
IReadOnlyList<string>? allowList,
|
IReadOnlyList<string>? allowList,
|
||||||
string sessionId,
|
string sessionId,
|
||||||
|
|
@ -40,21 +54,28 @@ internal sealed class GraphicalPluginSession : IDisposable
|
||||||
var plugins = new PluginSession(
|
var plugins = new PluginSession(
|
||||||
host,
|
host,
|
||||||
status => Report(statusWriter, sessionId, status));
|
status => Report(statusWriter, sessionId, status));
|
||||||
try
|
return new GraphicalPluginSession(
|
||||||
{
|
plugins,
|
||||||
plugins.Start(
|
|
||||||
[
|
[
|
||||||
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
||||||
paths.PluginsDirectory,
|
paths.PluginsDirectory,
|
||||||
],
|
],
|
||||||
allowList);
|
allowList,
|
||||||
return new GraphicalPluginSession(plugins);
|
sessionId,
|
||||||
}
|
statusWriter);
|
||||||
catch
|
}
|
||||||
{
|
|
||||||
plugins.Dispose();
|
internal void Start()
|
||||||
throw;
|
{
|
||||||
}
|
if (_started)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The graphical plugin session has already started.");
|
||||||
|
_started = true;
|
||||||
|
|
||||||
|
// Both real hosts publish the same startup prefix: started first,
|
||||||
|
// then one outcome for each configured plugin, then connection work.
|
||||||
|
_statusWriter.Started(_sessionId);
|
||||||
|
_plugins.Start(_roots, _allowList);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => _plugins.Dispose();
|
public void Dispose() => _plugins.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -161,12 +161,13 @@ var host = new AppPluginHost(
|
||||||
worldEvents,
|
worldEvents,
|
||||||
window.Selection,
|
window.Selection,
|
||||||
uiRegistry);
|
uiRegistry);
|
||||||
using var pluginSession = GraphicalPluginSession.Start(
|
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
|
||||||
applicationPaths,
|
applicationPaths,
|
||||||
runtimeOptions.Plugins,
|
runtimeOptions.Plugins,
|
||||||
runtimeOptions.SessionId ?? "app",
|
runtimeOptions.SessionId ?? "app",
|
||||||
host,
|
host,
|
||||||
window.StatusWriter);
|
window.StatusWriter);
|
||||||
|
window.StartPluginHosting(pluginSession);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -182,7 +183,6 @@ try
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
pluginSession.Dispose();
|
|
||||||
Log.CloseAndFlush();
|
Log.CloseAndFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -431,6 +431,7 @@ public sealed class GameWindow :
|
||||||
_creatureAppraisalFramePresenter;
|
_creatureAppraisalFramePresenter;
|
||||||
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
||||||
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
||||||
|
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
|
||||||
// Campaign V slice V11 deleted the ImGui developer-tools frontend along
|
// Campaign V slice V11 deleted the ImGui developer-tools frontend along
|
||||||
// with the OpenGL backend it required, so no host ever composes a
|
// with the OpenGL backend it required, so no host ever composes a
|
||||||
// developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches
|
// developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches
|
||||||
|
|
@ -723,6 +724,24 @@ public sealed class GameWindow :
|
||||||
_movementTruthDiagnostics);
|
_movementTruthDiagnostics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transfers the graphical plugin lifetime into the window shutdown graph
|
||||||
|
/// and starts it before retained UI construction drains registrations.
|
||||||
|
/// </summary>
|
||||||
|
internal void StartPluginHosting(
|
||||||
|
AcDream.App.Plugins.GraphicalPluginSession pluginSession)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(pluginSession);
|
||||||
|
if (_pluginSession is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The graphical plugin session is already attached.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_pluginSession = pluginSession;
|
||||||
|
pluginSession.Start();
|
||||||
|
}
|
||||||
|
|
||||||
public void Run()
|
public void Run()
|
||||||
{
|
{
|
||||||
_platformServices.ConfigureWindowBackend();
|
_platformServices.ConfigureWindowBackend();
|
||||||
|
|
@ -1555,9 +1574,7 @@ public sealed class GameWindow :
|
||||||
sessionPlayer),
|
sessionPlayer),
|
||||||
frameRoots => new SessionStartCompositionPhase(
|
frameRoots => new SessionStartCompositionPhase(
|
||||||
new SessionStartDependencies(
|
new SessionStartDependencies(
|
||||||
Console.WriteLine,
|
Console.WriteLine))
|
||||||
_statusWriter,
|
|
||||||
_options.SessionId ?? "app"))
|
|
||||||
.Start(frameRoots));
|
.Start(frameRoots));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1704,6 +1721,7 @@ public sealed class GameWindow :
|
||||||
_kbSource,
|
_kbSource,
|
||||||
_retailUiLease,
|
_retailUiLease,
|
||||||
_uiHost,
|
_uiHost,
|
||||||
|
_pluginSession,
|
||||||
_runtime,
|
_runtime,
|
||||||
_movementInput,
|
_movementInput,
|
||||||
_cameraInput,
|
_cameraInput,
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ internal sealed record IngressShutdownRoots(
|
||||||
RetailUiRuntimeLease RetailUi,
|
RetailUiRuntimeLease RetailUi,
|
||||||
// Keeps failed physical UI bindings alive through native-window release.
|
// Keeps failed physical UI bindings alive through native-window release.
|
||||||
UiHost? RetainedUiHost,
|
UiHost? RetainedUiHost,
|
||||||
|
IDisposable? Plugins,
|
||||||
GameRuntime Runtime,
|
GameRuntime Runtime,
|
||||||
DispatcherMovementInputSource MovementInput,
|
DispatcherMovementInputSource MovementInput,
|
||||||
DispatcherCameraInputSource CameraInput,
|
DispatcherCameraInputSource CameraInput,
|
||||||
|
|
@ -422,6 +423,10 @@ internal static class GameWindowShutdownManifest
|
||||||
Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)),
|
Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)),
|
||||||
Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)),
|
Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)),
|
||||||
]),
|
]),
|
||||||
|
new ResourceShutdownStage("plugin host",
|
||||||
|
[
|
||||||
|
Hard("plugins", () => ingress.Plugins?.Dispose()),
|
||||||
|
]),
|
||||||
new ResourceShutdownStage("frame borrowers",
|
new ResourceShutdownStage("frame borrowers",
|
||||||
[
|
[
|
||||||
Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()),
|
Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()),
|
||||||
|
|
|
||||||
|
|
@ -3273,10 +3273,12 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_bindings.Assets.ResolveSprite,
|
_bindings.Assets.ResolveSprite,
|
||||||
_bindings.Assets.Controls);
|
_bindings.Assets.Controls);
|
||||||
Host.Root.AddChild(element);
|
Host.Root.AddChild(element);
|
||||||
|
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
|
||||||
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
|
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
_bindings.Plugins.FailMount(panel);
|
||||||
Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}");
|
Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,17 @@ namespace AcDream.Core.Plugins;
|
||||||
/// Outcome of a plugin load attempt.
|
/// Outcome of a plugin load attempt.
|
||||||
/// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/>
|
/// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/>
|
||||||
/// owns its assembly, and <see cref="Error"/> is null.</para>
|
/// owns its assembly, and <see cref="Error"/> is null.</para>
|
||||||
/// <para>On failure, <see cref="Plugin"/> and <see cref="LoadContext"/> are null and
|
/// <para>On failure, <see cref="Plugin"/> and <see cref="LoadContext"/> are null,
|
||||||
/// <see cref="Error"/> describes what went wrong.</para>
|
/// <see cref="Error"/> describes what went wrong, and
|
||||||
|
/// <see cref="ReleasedLoadContext"/> weakly observes any collectible context
|
||||||
|
/// that was already released during rollback.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record LoadedPlugin(
|
public sealed record LoadedPlugin(
|
||||||
PluginManifest Manifest,
|
PluginManifest Manifest,
|
||||||
IAcDreamPlugin? Plugin,
|
IAcDreamPlugin? Plugin,
|
||||||
AssemblyLoadContext? LoadContext,
|
AssemblyLoadContext? LoadContext,
|
||||||
Exception? Error)
|
Exception? Error,
|
||||||
|
WeakReference? ReleasedLoadContext = null)
|
||||||
{
|
{
|
||||||
public bool Success => Plugin is not null && Error is null;
|
public bool Success => Plugin is not null && Error is null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,15 @@ public static class PluginLoader
|
||||||
|
|
||||||
if (pluginType is null)
|
if (pluginType is null)
|
||||||
{
|
{
|
||||||
|
var released = new WeakReference(alc);
|
||||||
alc.Unload();
|
alc.Unload();
|
||||||
return new LoadedPlugin(
|
return new LoadedPlugin(
|
||||||
manifest,
|
manifest,
|
||||||
Plugin: null,
|
Plugin: null,
|
||||||
LoadContext: null,
|
LoadContext: null,
|
||||||
Error: new InvalidOperationException(
|
Error: new InvalidOperationException(
|
||||||
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
|
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"),
|
||||||
|
ReleasedLoadContext: released);
|
||||||
}
|
}
|
||||||
|
|
||||||
instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
|
instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
|
||||||
|
|
@ -68,9 +70,15 @@ public static class PluginLoader
|
||||||
// as an Enable failure before releasing the collectible context.
|
// as an Enable failure before releasing the collectible context.
|
||||||
try { instance?.Disable(); }
|
try { instance?.Disable(); }
|
||||||
catch { }
|
catch { }
|
||||||
|
WeakReference? released = alc is null ? null : new WeakReference(alc);
|
||||||
try { alc?.Unload(); }
|
try { alc?.Unload(); }
|
||||||
catch { }
|
catch { }
|
||||||
return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex);
|
return new LoadedPlugin(
|
||||||
|
manifest,
|
||||||
|
Plugin: null,
|
||||||
|
LoadContext: null,
|
||||||
|
Error: ex,
|
||||||
|
ReleasedLoadContext: released);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ public sealed class PluginSession : IDisposable
|
||||||
{
|
{
|
||||||
private readonly IPluginHost _host;
|
private readonly IPluginHost _host;
|
||||||
private readonly Action<PluginSessionStatus>? _report;
|
private readonly Action<PluginSessionStatus>? _report;
|
||||||
private readonly List<LoadedPlugin> _loaded = [];
|
private readonly List<ActivePlugin> _loaded = [];
|
||||||
|
private readonly List<WeakReference> _releasedContexts = [];
|
||||||
private bool _started;
|
private bool _started;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
|
@ -42,7 +43,7 @@ public sealed class PluginSession : IDisposable
|
||||||
public int LoadedCount => _loaded.Count;
|
public int LoadedCount => _loaded.Count;
|
||||||
|
|
||||||
public IReadOnlyList<string> LoadedPluginIds =>
|
public IReadOnlyList<string> LoadedPluginIds =>
|
||||||
_loaded.Select(static plugin => plugin.Manifest.Id).ToArray();
|
_loaded.Select(static active => active.Loaded.Manifest.Id).ToArray();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Discovers and starts the configured set exactly once. A
|
/// Discovers and starts the configured set exactly once. A
|
||||||
|
|
@ -143,9 +144,11 @@ public sealed class PluginSession : IDisposable
|
||||||
/// owned by this session. The returned weak references do not delay unload.
|
/// owned by this session. The returned weak references do not delay unload.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
public IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||||
_loaded
|
[
|
||||||
.Select(static plugin => new WeakReference(plugin.LoadContext!))
|
.. _releasedContexts,
|
||||||
.ToArray();
|
.. _loaded.Select(static active =>
|
||||||
|
new WeakReference(active.Loaded.LoadContext!)),
|
||||||
|
];
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|
@ -155,7 +158,8 @@ public sealed class PluginSession : IDisposable
|
||||||
|
|
||||||
for (int index = _loaded.Count - 1; index >= 0; index--)
|
for (int index = _loaded.Count - 1; index >= 0; index--)
|
||||||
{
|
{
|
||||||
LoadedPlugin loaded = _loaded[index];
|
ActivePlugin active = _loaded[index];
|
||||||
|
LoadedPlugin loaded = active.Loaded;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
loaded.Plugin!.Disable();
|
loaded.Plugin!.Disable();
|
||||||
|
|
@ -169,6 +173,11 @@ public sealed class PluginSession : IDisposable
|
||||||
error);
|
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
|
try
|
||||||
{
|
{
|
||||||
loaded.LoadContext!.Unload();
|
loaded.LoadContext!.Unload();
|
||||||
|
|
@ -198,12 +207,16 @@ public sealed class PluginSession : IDisposable
|
||||||
{
|
{
|
||||||
foreach (PluginDiscoveryResult candidate in available)
|
foreach (PluginDiscoveryResult candidate in available)
|
||||||
{
|
{
|
||||||
|
var scope = new ScopedPluginHost(_host);
|
||||||
LoadedPlugin loaded = PluginLoader.Load(
|
LoadedPlugin loaded = PluginLoader.Load(
|
||||||
candidate.PluginDirectory,
|
candidate.PluginDirectory,
|
||||||
candidate.Manifest!,
|
candidate.Manifest!,
|
||||||
_host);
|
scope);
|
||||||
if (!loaded.Success)
|
if (!loaded.Success)
|
||||||
{
|
{
|
||||||
|
scope.Dispose();
|
||||||
|
if (loaded.ReleasedLoadContext is { } released)
|
||||||
|
_releasedContexts.Add(released);
|
||||||
AddError(
|
AddError(
|
||||||
errors,
|
errors,
|
||||||
id,
|
id,
|
||||||
|
|
@ -215,7 +228,7 @@ public sealed class PluginSession : IDisposable
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
loaded.Plugin!.Enable();
|
loaded.Plugin!.Enable();
|
||||||
_loaded.Add(loaded);
|
_loaded.Add(new ActivePlugin(loaded, scope));
|
||||||
SafeLog(
|
SafeLog(
|
||||||
static (log, message, _) => log.Info(message),
|
static (log, message, _) => log.Info(message),
|
||||||
$"plugin loaded: {loaded.Manifest.Id} "
|
$"plugin loaded: {loaded.Manifest.Id} "
|
||||||
|
|
@ -229,7 +242,7 @@ public sealed class PluginSession : IDisposable
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
AddError(errors, id, error);
|
AddError(errors, id, error);
|
||||||
ReleaseFailedEnable(loaded);
|
ReleaseFailedEnable(loaded, scope);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -257,7 +270,9 @@ public sealed class PluginSession : IDisposable
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReleaseFailedEnable(LoadedPlugin loaded)
|
private void ReleaseFailedEnable(
|
||||||
|
LoadedPlugin loaded,
|
||||||
|
ScopedPluginHost scope)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -272,6 +287,9 @@ public sealed class PluginSession : IDisposable
|
||||||
error);
|
error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scope.Dispose();
|
||||||
|
|
||||||
|
_releasedContexts.Add(new WeakReference(loaded.LoadContext!));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
loaded.LoadContext!.Unload();
|
loaded.LoadContext!.Unload();
|
||||||
|
|
@ -358,4 +376,8 @@ public sealed class PluginSession : IDisposable
|
||||||
or ArgumentException
|
or ArgumentException
|
||||||
or NotSupportedException
|
or NotSupportedException
|
||||||
or System.Security.SecurityException;
|
or System.Security.SecurityException;
|
||||||
|
|
||||||
|
private sealed record ActivePlugin(
|
||||||
|
LoadedPlugin Loaded,
|
||||||
|
ScopedPluginHost Scope);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
171
src/AcDream.Core/Plugins/ScopedPluginHost.cs
Normal file
171
src/AcDream.Core/Plugins/ScopedPluginHost.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-plugin host view that owns every registration made through the public
|
||||||
|
/// event/UI surfaces. Disposal is the host's rollback boundary: it removes
|
||||||
|
/// registrations even when plugin Initialize/Enable/Disable code throws.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IPluginHost _inner;
|
||||||
|
private readonly ScopedEvents _events;
|
||||||
|
private readonly ScopedUiRegistry _ui;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal ScopedPluginHost(IPluginHost inner)
|
||||||
|
{
|
||||||
|
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||||
|
_events = new ScopedEvents(inner.Events);
|
||||||
|
_ui = new ScopedUiRegistry(inner.Ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasUi => _inner.HasUi;
|
||||||
|
public IPluginLogger Log => _inner.Log;
|
||||||
|
public IGameState State => _inner.State;
|
||||||
|
public IEvents Events => _events;
|
||||||
|
public ISelectionService Selection => _inner.Selection;
|
||||||
|
public IUiRegistry Ui => _ui;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_disposed = true;
|
||||||
|
_events.Dispose();
|
||||||
|
_ui.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ScopedEvents(IEvents inner) : IEvents, IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly List<Action<WorldEntitySnapshot>> _registrations = [];
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||||
|
{
|
||||||
|
add
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
inner.EntitySpawned += value;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// A custom event source may mutate before its add accessor
|
||||||
|
// faults. Best-effort removal keeps the scope transactional.
|
||||||
|
try { inner.EntitySpawned -= value; }
|
||||||
|
catch { }
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_registrations.Add(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disposal may race the host subscription call. In that case
|
||||||
|
// the disposal snapshot could not see this registration, so
|
||||||
|
// the attaching thread must roll it back before returning.
|
||||||
|
try { inner.EntitySpawned -= value; }
|
||||||
|
catch { }
|
||||||
|
throw new ObjectDisposedException(nameof(ScopedEvents));
|
||||||
|
}
|
||||||
|
remove
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
return;
|
||||||
|
inner.EntitySpawned -= value;
|
||||||
|
lock (_gate)
|
||||||
|
RemoveLast(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Action<WorldEntitySnapshot>[] registrations;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_disposed = true;
|
||||||
|
registrations = _registrations.ToArray();
|
||||||
|
_registrations.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int index = registrations.Length - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
try { inner.EntitySpawned -= registrations[index]; }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveLast(Action<WorldEntitySnapshot> handler)
|
||||||
|
{
|
||||||
|
for (int index = _registrations.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
if (_registrations[index] != handler)
|
||||||
|
continue;
|
||||||
|
_registrations.RemoveAt(index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ScopedUiRegistry : IUiRegistry, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IScopedUiRegistry _inner;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly List<IDisposable> _registrations = [];
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal ScopedUiRegistry(IUiRegistry inner)
|
||||||
|
{
|
||||||
|
_inner = inner as IScopedUiRegistry
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddMarkupPanel(string markupPath, object binding)
|
||||||
|
{
|
||||||
|
IDisposable registration = _inner.RegisterMarkupPanel(
|
||||||
|
markupPath,
|
||||||
|
binding);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_registrations.Add(registration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registration.Dispose();
|
||||||
|
throw new ObjectDisposedException(nameof(ScopedUiRegistry));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
IDisposable[] registrations;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_disposed = true;
|
||||||
|
registrations = _registrations.ToArray();
|
||||||
|
_registrations.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int index = registrations.Length - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
try { registrations[index].Dispose(); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -301,7 +301,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
// descriptor.StatusFile is unset — every call site below stays
|
// descriptor.StatusFile is unset — every call site below stays
|
||||||
// unconditional.
|
// unconditional.
|
||||||
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
|
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
|
||||||
pluginSession = HeadlessPluginSession.Start(
|
pluginSession = HeadlessPluginSession.Create(
|
||||||
runtime,
|
runtime,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
statusWriter,
|
statusWriter,
|
||||||
|
|
@ -488,6 +488,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
// Campaign LA slice LA1: "started" = session host start — the
|
// Campaign LA slice LA1: "started" = session host start — the
|
||||||
// earliest point this session actually attempts to connect.
|
// earliest point this session actually attempts to connect.
|
||||||
_statusWriter.Started(_descriptor.Id);
|
_statusWriter.Started(_descriptor.Id);
|
||||||
|
_pluginSession.Start();
|
||||||
RuntimeSessionStartResult result =
|
RuntimeSessionStartResult result =
|
||||||
Commands.Session.Start(Runtime.Generation);
|
Commands.Session.Start(Runtime.Generation);
|
||||||
_startOutcome = result.Status;
|
_startOutcome = result.Status;
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,23 @@ internal sealed class HeadlessPluginHost
|
||||||
private readonly GameRuntime _runtime;
|
private readonly GameRuntime _runtime;
|
||||||
private readonly IDisposable _eventSubscription;
|
private readonly IDisposable _eventSubscription;
|
||||||
private readonly object _eventGate = new();
|
private readonly object _eventGate = new();
|
||||||
private Action<WorldEntitySnapshot>? _entitySpawned;
|
private readonly List<Subscription> _subscriptions = [];
|
||||||
|
private Subscription[] _liveSnapshot = [];
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
private readonly record struct ReplayEntity(
|
||||||
|
RuntimeEntityIdentity Identity,
|
||||||
|
WorldEntitySnapshot Snapshot);
|
||||||
|
|
||||||
|
private sealed class Subscription(Action<WorldEntitySnapshot> handler)
|
||||||
|
{
|
||||||
|
internal Action<WorldEntitySnapshot> Handler { get; } = handler;
|
||||||
|
internal Queue<ReplayEntity> Pending { get; } = new();
|
||||||
|
internal HashSet<RuntimeEntityIdentity> Delivered { get; } = [];
|
||||||
|
internal bool Replaying { get; set; } = true;
|
||||||
|
internal bool Active { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
internal HeadlessPluginHost(
|
internal HeadlessPluginHost(
|
||||||
GameRuntime runtime,
|
GameRuntime runtime,
|
||||||
IPluginLogger logger)
|
IPluginLogger logger)
|
||||||
|
|
@ -38,6 +52,10 @@ internal sealed class HeadlessPluginHost
|
||||||
public ISelectionService Selection => _runtime.ActionOwner.Selection;
|
public ISelectionService Selection => _runtime.ActionOwner.Selection;
|
||||||
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||||
|
|
||||||
|
/// <summary>Test-only barrier after the borrowed replay snapshot is
|
||||||
|
/// captured and before delivery starts.</summary>
|
||||||
|
internal Action? ReplayCapturedForTest { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Immutable point-in-time values produced directly from Runtime on each
|
/// Immutable point-in-time values produced directly from Runtime on each
|
||||||
/// read. The caller owns the returned snapshot list; this host retains no
|
/// read. The caller owns the returned snapshot list; this host retains no
|
||||||
|
|
@ -50,7 +68,7 @@ internal sealed class HeadlessPluginHost
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
var visitor = new SnapshotVisitor(_runtime);
|
var visitor = new SnapshotVisitor(_runtime);
|
||||||
_runtime.Entities.Visit(visitor);
|
_runtime.Entities.Visit(visitor);
|
||||||
return visitor.Snapshots;
|
return visitor.Items.Select(static item => item.Snapshot).ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,20 +78,81 @@ internal sealed class HeadlessPluginHost
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
var subscription = new Subscription(value);
|
||||||
lock (_eventGate)
|
lock (_eventGate)
|
||||||
_entitySpawned += value;
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
_subscriptions.Add(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
// Match the graphical WorldEvents contract: a late subscriber
|
// Arm the pending queue before borrowing Runtime's snapshot. This
|
||||||
// immediately observes the canonical world that exists now.
|
// avoids a host-lock/Runtime-lock inversion while the identity
|
||||||
foreach (WorldEntitySnapshot snapshot in Entities)
|
// dedup below collapses any registration present in both views.
|
||||||
Invoke(value, snapshot);
|
var visitor = new SnapshotVisitor(_runtime);
|
||||||
|
_runtime.Entities.Visit(visitor);
|
||||||
|
ReplayEntity[] replay = visitor.Items.ToArray();
|
||||||
|
|
||||||
|
ReplayCapturedForTest?.Invoke();
|
||||||
|
foreach (ReplayEntity item in replay)
|
||||||
|
{
|
||||||
|
lock (_eventGate)
|
||||||
|
{
|
||||||
|
if (!subscription.Active)
|
||||||
|
return;
|
||||||
|
if (!_runtime.Entities.TryGet(
|
||||||
|
item.Identity.ServerGuid,
|
||||||
|
out RuntimeEntitySnapshot current)
|
||||||
|
|| current.Identity != item.Identity
|
||||||
|
|| !subscription.Delivered.Add(item.Identity))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke(subscription.Handler, item.Snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
ReplayEntity pending;
|
||||||
|
lock (_eventGate)
|
||||||
|
{
|
||||||
|
if (!subscription.Active)
|
||||||
|
return;
|
||||||
|
if (!subscription.Pending.TryDequeue(out pending))
|
||||||
|
{
|
||||||
|
subscription.Replaying = false;
|
||||||
|
subscription.Delivered.Clear();
|
||||||
|
RebuildLiveSnapshotLocked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!subscription.Delivered.Add(pending.Identity))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke(subscription.Handler, pending.Snapshot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
remove
|
remove
|
||||||
{
|
{
|
||||||
if (value is null)
|
if (value is null)
|
||||||
return;
|
return;
|
||||||
lock (_eventGate)
|
lock (_eventGate)
|
||||||
_entitySpawned -= value;
|
{
|
||||||
|
for (int index = _subscriptions.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
Subscription subscription = _subscriptions[index];
|
||||||
|
if (subscription.Handler != value)
|
||||||
|
continue;
|
||||||
|
subscription.Active = false;
|
||||||
|
subscription.Pending.Clear();
|
||||||
|
subscription.Delivered.Clear();
|
||||||
|
_subscriptions.RemoveAt(index);
|
||||||
|
if (!subscription.Replaying)
|
||||||
|
RebuildLiveSnapshotLocked();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,28 +160,45 @@ internal sealed class HeadlessPluginHost
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
return;
|
return;
|
||||||
_eventSubscription.Dispose();
|
|
||||||
_disposed = true;
|
|
||||||
lock (_eventGate)
|
lock (_eventGate)
|
||||||
_entitySpawned = null;
|
{
|
||||||
|
_disposed = true;
|
||||||
|
foreach (Subscription subscription in _subscriptions)
|
||||||
|
{
|
||||||
|
subscription.Active = false;
|
||||||
|
subscription.Pending.Clear();
|
||||||
|
subscription.Delivered.Clear();
|
||||||
|
}
|
||||||
|
_subscriptions.Clear();
|
||||||
|
_liveSnapshot = [];
|
||||||
|
}
|
||||||
|
_eventSubscription.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnEntity(in RuntimeEntityDelta delta)
|
public void OnEntity(in RuntimeEntityDelta delta)
|
||||||
{
|
{
|
||||||
if (_disposed || delta.Change != RuntimeEntityChange.Registered)
|
if (delta.Change != RuntimeEntityChange.Registered)
|
||||||
return;
|
return;
|
||||||
Action<WorldEntitySnapshot>? handlers;
|
Subscription[] toNotify;
|
||||||
|
var pending = new ReplayEntity(
|
||||||
|
delta.Entity.Identity,
|
||||||
|
Convert(_runtime, delta.Entity));
|
||||||
lock (_eventGate)
|
lock (_eventGate)
|
||||||
handlers = _entitySpawned;
|
{
|
||||||
if (handlers is null)
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
foreach (Subscription subscription in _subscriptions)
|
||||||
|
{
|
||||||
|
if (subscription.Active && subscription.Replaying)
|
||||||
|
subscription.Pending.Enqueue(pending);
|
||||||
|
}
|
||||||
|
toNotify = _liveSnapshot;
|
||||||
|
}
|
||||||
|
if (toNotify.Length == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
WorldEntitySnapshot snapshot = Convert(_runtime, delta.Entity);
|
foreach (Subscription subscription in toNotify)
|
||||||
foreach (Action<WorldEntitySnapshot> handler
|
Invoke(subscription.Handler, pending.Snapshot);
|
||||||
in handlers.GetInvocationList().Cast<Action<WorldEntitySnapshot>>())
|
|
||||||
{
|
|
||||||
Invoke(handler, snapshot);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
|
public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
|
||||||
|
|
@ -141,10 +237,20 @@ internal sealed class HeadlessPluginHost
|
||||||
private sealed class SnapshotVisitor(GameRuntime runtime)
|
private sealed class SnapshotVisitor(GameRuntime runtime)
|
||||||
: IRuntimeEntityVisitor
|
: IRuntimeEntityVisitor
|
||||||
{
|
{
|
||||||
internal List<WorldEntitySnapshot> Snapshots { get; } =
|
internal List<ReplayEntity> Items { get; } =
|
||||||
new(runtime.Entities.Count);
|
new(runtime.Entities.Count);
|
||||||
|
|
||||||
public void Visit(in RuntimeEntitySnapshot entity) =>
|
public void Visit(in RuntimeEntitySnapshot entity) =>
|
||||||
Snapshots.Add(Convert(runtime, entity));
|
Items.Add(new ReplayEntity(
|
||||||
|
entity.Identity,
|
||||||
|
Convert(runtime, entity)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildLiveSnapshotLocked()
|
||||||
|
{
|
||||||
|
_liveSnapshot = _subscriptions
|
||||||
|
.Where(static subscription =>
|
||||||
|
subscription.Active && !subscription.Replaying)
|
||||||
|
.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,24 +14,31 @@ internal sealed class HeadlessPluginSession : IDisposable
|
||||||
{
|
{
|
||||||
private readonly HeadlessPluginHost _host;
|
private readonly HeadlessPluginHost _host;
|
||||||
private readonly PluginSession _plugins;
|
private readonly PluginSession _plugins;
|
||||||
|
private readonly string[] _roots;
|
||||||
|
private readonly IReadOnlyList<string>? _allowList;
|
||||||
private int _disposeStage;
|
private int _disposeStage;
|
||||||
|
private bool _started;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
private HeadlessPluginSession(
|
private HeadlessPluginSession(
|
||||||
HeadlessPluginHost host,
|
HeadlessPluginHost host,
|
||||||
PluginSession plugins)
|
PluginSession plugins,
|
||||||
|
string[] roots,
|
||||||
|
IReadOnlyList<string>? allowList)
|
||||||
{
|
{
|
||||||
_host = host;
|
_host = host;
|
||||||
_plugins = plugins;
|
_plugins = plugins;
|
||||||
|
_roots = roots;
|
||||||
|
_allowList = allowList;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal int LoadedCount => _plugins.LoadedCount;
|
internal int LoadedCount => _plugins.LoadedCount;
|
||||||
internal IPluginHost Host => _host;
|
internal HeadlessPluginHost Host => _host;
|
||||||
|
|
||||||
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||||
_plugins.CaptureLoadContextWeakReferences();
|
_plugins.CaptureLoadContextWeakReferences();
|
||||||
|
|
||||||
internal static HeadlessPluginSession Start(
|
internal static HeadlessPluginSession Create(
|
||||||
GameRuntime runtime,
|
GameRuntime runtime,
|
||||||
HeadlessDiagnosticWriter diagnostics,
|
HeadlessDiagnosticWriter diagnostics,
|
||||||
SessionStatusWriter statusWriter,
|
SessionStatusWriter statusWriter,
|
||||||
|
|
@ -54,17 +61,21 @@ internal sealed class HeadlessPluginSession : IDisposable
|
||||||
var plugins = new PluginSession(
|
var plugins = new PluginSession(
|
||||||
host,
|
host,
|
||||||
status => Report(statusWriter, sessionId, status));
|
status => Report(statusWriter, sessionId, status));
|
||||||
try
|
return new HeadlessPluginSession(
|
||||||
{
|
host,
|
||||||
plugins.Start(roots, allowList);
|
plugins,
|
||||||
return new HeadlessPluginSession(host, plugins);
|
roots.ToArray(),
|
||||||
}
|
allowList);
|
||||||
catch
|
}
|
||||||
{
|
|
||||||
plugins.Dispose();
|
internal void Start()
|
||||||
host.Dispose();
|
{
|
||||||
throw;
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
}
|
if (_started)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The headless plugin session has already started.");
|
||||||
|
_started = true;
|
||||||
|
_plugins.Start(_roots, _allowList);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,9 @@ public static class SessionConfigComposer
|
||||||
Character = selector,
|
Character = selector,
|
||||||
Policy = policy,
|
Policy = policy,
|
||||||
Credential = new SessionCredentialDescriptor(),
|
Credential = new SessionCredentialDescriptor(),
|
||||||
Plugins = character.Plugins.Count > 0 ? [.. character.Plugins] : null,
|
// LA5 distinguishes an omitted allow-list (load all, preserving
|
||||||
|
// the developer flow) from an explicit empty list (load none).
|
||||||
|
Plugins = [.. character.Plugins],
|
||||||
LoginCommands = character.LoginCommands.Count > 0
|
LoginCommands = character.LoginCommands.Count > 0
|
||||||
? [.. character.LoginCommands]
|
? [.. character.LoginCommands]
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -107,9 +109,9 @@ public static class SessionConfigComposer
|
||||||
/// §LA3 review finding F2): the session carries <c>mode: "probe"</c>,
|
/// §LA3 review finding F2): the session carries <c>mode: "probe"</c>,
|
||||||
/// no <c>character</c> selector, and no <c>policy</c> — the host
|
/// no <c>character</c> selector, and no <c>policy</c> — the host
|
||||||
/// reports the account's character roster over the status stream and
|
/// reports the account's character roster over the status stream and
|
||||||
/// exits without entering the world. <c>plugins</c>/<c>loginCommands</c>
|
/// exits without entering the world. Probes carry an explicit empty
|
||||||
/// don't apply to a probe and are always omitted, exactly like an
|
/// <c>plugins</c> allow-list so a plugin installed on the machine cannot
|
||||||
/// empty configured set on a normal session.
|
/// run merely because the probe has no character-level plugin settings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ComposedSessionConfig ComposeProbe(
|
public static ComposedSessionConfig ComposeProbe(
|
||||||
ServerProfile server,
|
ServerProfile server,
|
||||||
|
|
@ -139,7 +141,7 @@ public static class SessionConfigComposer
|
||||||
Character = null,
|
Character = null,
|
||||||
Policy = null,
|
Policy = null,
|
||||||
Credential = new SessionCredentialDescriptor(),
|
Credential = new SessionCredentialDescriptor(),
|
||||||
Plugins = null,
|
Plugins = [],
|
||||||
LoginCommands = null,
|
LoginCommands = null,
|
||||||
LoginCommandDelayMs = null,
|
LoginCommandDelayMs = null,
|
||||||
StatusFile = statusFilePath,
|
StatusFile = statusFilePath,
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,24 @@ public interface IUiRegistry
|
||||||
void AddMarkupPanel(string markupPath, object binding);
|
void AddMarkupPanel(string markupPath, object binding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host-infrastructure extension used to give each plugin a removable UI
|
||||||
|
/// registration lifetime. Plugins continue to call
|
||||||
|
/// <see cref="IUiRegistry.AddMarkupPanel"/>; the shared plugin host wraps that
|
||||||
|
/// call and owns the returned token so failed initialization, failed enable,
|
||||||
|
/// and shutdown can roll the registration back without trusting plugin code.
|
||||||
|
/// </summary>
|
||||||
|
public interface IScopedUiRegistry : IUiRegistry
|
||||||
|
{
|
||||||
|
IDisposable RegisterMarkupPanel(string markupPath, object binding);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// BCL-only UI sink for no-window plugin hosts. It intentionally retains
|
/// BCL-only UI sink for no-window plugin hosts. It intentionally retains
|
||||||
/// neither markup paths nor binding objects, so a UI registration cannot keep
|
/// neither markup paths nor binding objects, so a UI registration cannot keep
|
||||||
/// a plugin assembly alive after its collectible load context is unloaded.
|
/// a plugin assembly alive after its collectible load context is unloaded.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NoOpUiRegistry : IUiRegistry
|
public sealed class NoOpUiRegistry : IScopedUiRegistry
|
||||||
{
|
{
|
||||||
public static NoOpUiRegistry Instance { get; } = new();
|
public static NoOpUiRegistry Instance { get; } = new();
|
||||||
|
|
||||||
|
|
@ -31,4 +43,16 @@ public sealed class NoOpUiRegistry : IUiRegistry
|
||||||
public void AddMarkupPanel(string markupPath, object binding)
|
public void AddMarkupPanel(string markupPath, object binding)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IDisposable RegisterMarkupPanel(string markupPath, object binding) =>
|
||||||
|
NoOpRegistration.Instance;
|
||||||
|
|
||||||
|
private sealed class NoOpRegistration : IDisposable
|
||||||
|
{
|
||||||
|
internal static NoOpRegistration Instance { get; } = new();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,18 @@ public sealed class SessionConfigurationSharedFixtureTests
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppReaderPreservesLauncherExplicitEmptyPluginAllowList()
|
||||||
|
{
|
||||||
|
using TemporaryFile file = TemporaryFile.Create(
|
||||||
|
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
|
||||||
|
|
||||||
|
(_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path);
|
||||||
|
|
||||||
|
Assert.NotNull(session.Plugins);
|
||||||
|
Assert.Empty(session.Plugins);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AppReaderAcceptsTheProductionShapedSharedFixture()
|
public void AppReaderAcceptsTheProductionShapedSharedFixture()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using AcDream.App.Plugins;
|
using AcDream.App.Plugins;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Plugins;
|
namespace AcDream.App.Tests.Plugins;
|
||||||
|
|
||||||
|
|
@ -18,4 +19,26 @@ public class BufferedUiRegistryTests
|
||||||
|
|
||||||
Assert.Empty(reg.Drain()); // consumed
|
Assert.Empty(reg.Drain()); // consumed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScopedRegistrationTokenRemovesAnAlreadyMountedElement()
|
||||||
|
{
|
||||||
|
var registry = new BufferedUiRegistry();
|
||||||
|
IDisposable registration = registry.RegisterMarkupPanel(
|
||||||
|
"plugin.xml",
|
||||||
|
new object());
|
||||||
|
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
|
||||||
|
var root = new UiRoot();
|
||||||
|
var element = new UiPanel();
|
||||||
|
root.AddChild(element);
|
||||||
|
registry.CompleteMount(pending, root, element);
|
||||||
|
|
||||||
|
Assert.Contains(element, root.Children);
|
||||||
|
Assert.Equal(1, registry.RegistrationCount);
|
||||||
|
|
||||||
|
registration.Dispose();
|
||||||
|
|
||||||
|
Assert.DoesNotContain(element, root.Children);
|
||||||
|
Assert.Equal(0, registry.RegistrationCount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using AcDream.App.Configuration;
|
||||||
using AcDream.App.Plugins;
|
using AcDream.App.Plugins;
|
||||||
using AcDream.Core.Plugins;
|
using AcDream.Core.Plugins;
|
||||||
using AcDream.Core.Selection;
|
using AcDream.Core.Selection;
|
||||||
using AcDream.Platform;
|
using AcDream.Platform;
|
||||||
using AcDream.Plugin.Abstractions;
|
using AcDream.Plugin.Abstractions;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
|
using AcDream.Tests.Fixtures.CampaignLa;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Plugins;
|
namespace AcDream.App.Tests.Plugins;
|
||||||
|
|
||||||
public sealed class GraphicalPluginSessionTests
|
public sealed class GraphicalPluginSessionTests
|
||||||
{
|
{
|
||||||
private const string FixtureId = "acdream.test.host-fixture";
|
private const string FixtureId = "acdream.test.host-fixture";
|
||||||
|
private const string ThrowingId = "acdream.test.throwing-fixture";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes()
|
public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes()
|
||||||
|
|
@ -27,12 +30,13 @@ public sealed class GraphicalPluginSessionTests
|
||||||
var ui = new BufferedUiRegistry();
|
var ui = new BufferedUiRegistry();
|
||||||
var host = new AppPluginHost(logger, state, events, selection, ui);
|
var host = new AppPluginHost(logger, state, events, selection, ui);
|
||||||
|
|
||||||
using GraphicalPluginSession plugins = GraphicalPluginSession.Start(
|
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
|
||||||
paths,
|
paths,
|
||||||
[FixtureId.ToUpperInvariant(), "acdream.test.missing"],
|
[FixtureId.ToUpperInvariant(), "acdream.test.missing"],
|
||||||
"gui-session",
|
"gui-session",
|
||||||
host,
|
host,
|
||||||
new SessionStatusWriter(statusPath));
|
new SessionStatusWriter(statusPath));
|
||||||
|
plugins.Start();
|
||||||
|
|
||||||
Assert.Equal(1, plugins.LoadedCount);
|
Assert.Equal(1, plugins.LoadedCount);
|
||||||
Assert.True(host.HasUi);
|
Assert.True(host.HasUi);
|
||||||
|
|
@ -42,19 +46,20 @@ public sealed class GraphicalPluginSessionTests
|
||||||
message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal));
|
message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal));
|
||||||
|
|
||||||
JsonElement[] statuses = ReadStatuses(statusPath);
|
JsonElement[] statuses = ReadStatuses(statusPath);
|
||||||
Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses));
|
Assert.Equal(["started", "pluginLoaded", "pluginFailed"], EventNames(statuses));
|
||||||
Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString());
|
Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
"acdream.test.missing",
|
"acdream.test.missing",
|
||||||
statuses[1].GetProperty("plugin").GetString());
|
statuses[2].GetProperty("plugin").GetString());
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"not found",
|
"not found",
|
||||||
statuses[1].GetProperty("error").GetString(),
|
statuses[2].GetProperty("error").GetString(),
|
||||||
StringComparison.OrdinalIgnoreCase);
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
WeakReference context = Assert.Single(
|
WeakReference context = Assert.Single(
|
||||||
plugins.CaptureLoadContextWeakReferences());
|
plugins.CaptureLoadContextWeakReferences());
|
||||||
plugins.Dispose();
|
plugins.Dispose();
|
||||||
|
Assert.Equal(0, ui.RegistrationCount);
|
||||||
Collect(context);
|
Collect(context);
|
||||||
Assert.False(context.IsAlive);
|
Assert.False(context.IsAlive);
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +71,12 @@ public sealed class GraphicalPluginSessionTests
|
||||||
ApplicationPathSet paths = Paths(temporary.Path);
|
ApplicationPathSet paths = Paths(temporary.Path);
|
||||||
InstallFixture(paths.PluginsDirectory, FixtureId);
|
InstallFixture(paths.PluginsDirectory, FixtureId);
|
||||||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||||
|
string configPath = Path.Combine(temporary.Path, "session.json");
|
||||||
|
File.WriteAllText(
|
||||||
|
configPath,
|
||||||
|
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
|
||||||
|
(_, SessionDescriptor descriptor) =
|
||||||
|
SessionConfigurationLoader.Load(configPath);
|
||||||
var ui = new BufferedUiRegistry();
|
var ui = new BufferedUiRegistry();
|
||||||
var host = new AppPluginHost(
|
var host = new AppPluginHost(
|
||||||
new CapturingLogger(),
|
new CapturingLogger(),
|
||||||
|
|
@ -74,16 +85,70 @@ public sealed class GraphicalPluginSessionTests
|
||||||
new SelectionState(),
|
new SelectionState(),
|
||||||
ui);
|
ui);
|
||||||
|
|
||||||
using GraphicalPluginSession plugins = GraphicalPluginSession.Start(
|
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
|
||||||
paths,
|
paths,
|
||||||
[],
|
descriptor.Plugins,
|
||||||
"gui-session",
|
"gui-session",
|
||||||
host,
|
host,
|
||||||
new SessionStatusWriter(statusPath));
|
new SessionStatusWriter(statusPath));
|
||||||
|
plugins.Start();
|
||||||
|
|
||||||
|
Assert.Equal(0, plugins.LoadedCount);
|
||||||
|
Assert.NotNull(descriptor.Plugins);
|
||||||
|
Assert.Empty(descriptor.Plugins);
|
||||||
|
Assert.Empty(ui.Drain());
|
||||||
|
Assert.Equal(["started"], EventNames(ReadStatuses(statusPath)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ThrowAfterRegistrationRollsBackUiAndEventsAndCollectsContext()
|
||||||
|
{
|
||||||
|
using var temporary = new TemporaryDirectory();
|
||||||
|
ApplicationPathSet paths = Paths(temporary.Path);
|
||||||
|
string pluginDirectory = InstallFixture(
|
||||||
|
paths.PluginsDirectory,
|
||||||
|
ThrowingId,
|
||||||
|
"throwing-fixture");
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(pluginDirectory, "throw-after-register"),
|
||||||
|
string.Empty);
|
||||||
|
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||||
|
var events = new WorldEvents();
|
||||||
|
var ui = new BufferedUiRegistry();
|
||||||
|
var host = new AppPluginHost(
|
||||||
|
new CapturingLogger(),
|
||||||
|
new WorldGameState(),
|
||||||
|
events,
|
||||||
|
new SelectionState(),
|
||||||
|
ui);
|
||||||
|
|
||||||
|
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
|
||||||
|
paths,
|
||||||
|
[ThrowingId],
|
||||||
|
"gui-session",
|
||||||
|
host,
|
||||||
|
new SessionStatusWriter(statusPath));
|
||||||
|
plugins.Start();
|
||||||
|
|
||||||
Assert.Equal(0, plugins.LoadedCount);
|
Assert.Equal(0, plugins.LoadedCount);
|
||||||
Assert.Empty(ui.Drain());
|
Assert.Empty(ui.Drain());
|
||||||
Assert.False(File.Exists(statusPath));
|
Assert.Equal(0, ui.RegistrationCount);
|
||||||
|
events.FireEntitySpawned(new WorldEntitySnapshot(
|
||||||
|
1u,
|
||||||
|
2u,
|
||||||
|
default,
|
||||||
|
System.Numerics.Quaternion.Identity));
|
||||||
|
Assert.False(File.Exists(
|
||||||
|
Path.Combine(pluginDirectory, "unexpected-callback")));
|
||||||
|
Assert.Equal(
|
||||||
|
["started", "pluginFailed"],
|
||||||
|
EventNames(ReadStatuses(statusPath)));
|
||||||
|
|
||||||
|
WeakReference context = Assert.Single(
|
||||||
|
plugins.CaptureLoadContextWeakReferences());
|
||||||
|
plugins.Dispose();
|
||||||
|
Collect(context);
|
||||||
|
Assert.False(context.IsAlive);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ApplicationPathSet Paths(string root) => new(
|
private static ApplicationPathSet Paths(string root) => new(
|
||||||
|
|
@ -114,11 +179,14 @@ public sealed class GraphicalPluginSessionTests
|
||||||
private static string[] EventNames(IEnumerable<JsonElement> events) =>
|
private static string[] EventNames(IEnumerable<JsonElement> events) =>
|
||||||
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
|
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
|
||||||
|
|
||||||
private static void InstallFixture(string root, string id)
|
private static string InstallFixture(
|
||||||
|
string root,
|
||||||
|
string id,
|
||||||
|
string directoryName = "host-fixture")
|
||||||
{
|
{
|
||||||
string source = FixtureAssemblyPath();
|
string source = FixtureAssemblyPath();
|
||||||
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
||||||
string pluginDirectory = Path.Combine(root, "host-fixture");
|
string pluginDirectory = Path.Combine(root, directoryName);
|
||||||
Directory.CreateDirectory(pluginDirectory);
|
Directory.CreateDirectory(pluginDirectory);
|
||||||
string fileName = Path.GetFileName(source);
|
string fileName = Path.GetFileName(source);
|
||||||
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
||||||
|
|
@ -132,6 +200,7 @@ public sealed class GraphicalPluginSessionTests
|
||||||
entryDll = fileName,
|
entryDll = fileName,
|
||||||
apiVersion = 1,
|
apiVersion = 1,
|
||||||
}));
|
}));
|
||||||
|
return pluginDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FixtureAssemblyPath()
|
private static string FixtureAssemblyPath()
|
||||||
|
|
@ -195,8 +264,22 @@ public sealed class GraphicalPluginSessionTests
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (Directory.Exists(Path))
|
for (int attempt = 0; Directory.Exists(Path); attempt++)
|
||||||
Directory.Delete(Path, recursive: true);
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(Path, recursive: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
when (error is IOException or UnauthorizedAccessException
|
||||||
|
&& attempt < 9)
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
Thread.Sleep(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,11 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
|
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
|
||||||
{
|
{
|
||||||
string source = GameWindowSource();
|
string source = GameWindowSource();
|
||||||
|
string program = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Program.cs"));
|
||||||
string lifetime = GameWindowLifetimeSource();
|
string lifetime = GameWindowLifetimeSource();
|
||||||
string manifest = Slice(
|
string manifest = Slice(
|
||||||
lifetime,
|
lifetime,
|
||||||
|
|
@ -464,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
[
|
[
|
||||||
"new ResourceShutdownStage(\"host and session barriers\"",
|
"new ResourceShutdownStage(\"host and session barriers\"",
|
||||||
"new ResourceShutdownStage(\"physical ingress cleanup\"",
|
"new ResourceShutdownStage(\"physical ingress cleanup\"",
|
||||||
|
"new ResourceShutdownStage(\"plugin host\"",
|
||||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||||
"new ResourceShutdownStage(\"session dependents\"",
|
"new ResourceShutdownStage(\"session dependents\"",
|
||||||
"new ResourceShutdownStage(\"live entities\"",
|
"new ResourceShutdownStage(\"live entities\"",
|
||||||
|
|
@ -499,6 +505,8 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
|
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
|
||||||
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
|
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
|
||||||
"Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))",
|
"Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))",
|
||||||
|
"new ResourceShutdownStage(\"plugin host\"",
|
||||||
|
"Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
|
||||||
"new ResourceShutdownStage(\"session dependents\"",
|
"new ResourceShutdownStage(\"session dependents\"",
|
||||||
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
|
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
|
|
@ -509,7 +517,27 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"UiHost? RetainedUiHost,",
|
"UiHost? RetainedUiHost,",
|
||||||
lifetime,
|
lifetime,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"IDisposable? Plugins,",
|
||||||
|
lifetime,
|
||||||
|
StringComparison.Ordinal);
|
||||||
Assert.Contains("_uiHost,", source, StringComparison.Ordinal);
|
Assert.Contains("_uiHost,", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("_pluginSession,", source, StringComparison.Ordinal);
|
||||||
|
AssertAppearsInOrder(
|
||||||
|
program,
|
||||||
|
"GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(",
|
||||||
|
"window.StartPluginHosting(pluginSession);",
|
||||||
|
"window.Run();");
|
||||||
|
AssertAppearsInOrder(
|
||||||
|
source,
|
||||||
|
"_pluginSession = pluginSession;",
|
||||||
|
"pluginSession.Start();",
|
||||||
|
"public void Run()");
|
||||||
|
AssertAppearsInOrder(
|
||||||
|
manifest,
|
||||||
|
"Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
|
||||||
|
"Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))",
|
||||||
|
"Hard(\"game runtime\", () => DisposeGameRuntime(live.Runtime))");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
nativeRelease,
|
nativeRelease,
|
||||||
"TryComplete();",
|
"TryComplete();",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Net;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Headless.Configuration;
|
using AcDream.Headless.Configuration;
|
||||||
|
|
@ -7,6 +8,9 @@ using AcDream.Headless.Diagnostics;
|
||||||
using AcDream.Headless.Hosting;
|
using AcDream.Headless.Hosting;
|
||||||
using AcDream.Headless.Plugins;
|
using AcDream.Headless.Plugins;
|
||||||
using AcDream.Plugin.Abstractions;
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
using AcDream.Tests.Fixtures.CampaignLa;
|
||||||
|
|
||||||
namespace AcDream.Headless.Tests;
|
namespace AcDream.Headless.Tests;
|
||||||
|
|
||||||
|
|
@ -14,6 +18,7 @@ public sealed class HeadlessPluginSessionTests
|
||||||
{
|
{
|
||||||
private const string FixtureId = "acdream.test.host-fixture";
|
private const string FixtureId = "acdream.test.host-fixture";
|
||||||
private const string BrokenId = "acdream.test.broken";
|
private const string BrokenId = "acdream.test.broken";
|
||||||
|
private const string ThrowingId = "acdream.test.throwing-fixture";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads()
|
public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads()
|
||||||
|
|
@ -29,8 +34,10 @@ public sealed class HeadlessPluginSessionTests
|
||||||
Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath),
|
Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath),
|
||||||
credential,
|
credential,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
new FixtureSessionOperations(),
|
||||||
pluginRoots: [temporary.Path]);
|
pluginRoots: [temporary.Path]);
|
||||||
HeadlessPluginSession plugins = session.Plugins;
|
HeadlessPluginSession plugins = session.Plugins;
|
||||||
|
_ = session.Start();
|
||||||
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
|
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
|
||||||
|
|
||||||
Assert.Equal(1, plugins.LoadedCount);
|
Assert.Equal(1, plugins.LoadedCount);
|
||||||
|
|
@ -47,12 +54,17 @@ public sealed class HeadlessPluginSessionTests
|
||||||
Assert.Equal(2, plugins.Host.State.Entities.Count);
|
Assert.Equal(2, plugins.Host.State.Entities.Count);
|
||||||
|
|
||||||
JsonElement[] statuses = ReadStatuses(statusPath);
|
JsonElement[] statuses = ReadStatuses(statusPath);
|
||||||
Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses));
|
Assert.Equal(
|
||||||
Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString());
|
[
|
||||||
Assert.Equal(BrokenId, statuses[1].GetProperty("plugin").GetString());
|
"started", "pluginLoaded", "pluginFailed", "connected",
|
||||||
|
"characterList", "enteredWorld",
|
||||||
|
],
|
||||||
|
EventNames(statuses));
|
||||||
|
Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
|
||||||
|
Assert.Equal(BrokenId, statuses[2].GetProperty("plugin").GetString());
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"entry dll not found",
|
"entry dll not found",
|
||||||
statuses[1].GetProperty("error").GetString()!,
|
statuses[2].GetProperty("error").GetString()!,
|
||||||
StringComparison.OrdinalIgnoreCase);
|
StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString());
|
Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString());
|
||||||
|
|
||||||
|
|
@ -79,13 +91,126 @@ public sealed class HeadlessPluginSessionTests
|
||||||
Descriptor([], statusPath),
|
Descriptor([], statusPath),
|
||||||
credential,
|
credential,
|
||||||
new HeadlessDiagnosticWriter(output),
|
new HeadlessDiagnosticWriter(output),
|
||||||
|
new FixtureSessionOperations(),
|
||||||
pluginRoots: [temporary.Path]);
|
pluginRoots: [temporary.Path]);
|
||||||
|
|
||||||
|
_ = session.Start();
|
||||||
Assert.Equal(0, session.Plugins.LoadedCount);
|
Assert.Equal(0, session.Plugins.LoadedCount);
|
||||||
Assert.False(File.Exists(statusPath));
|
Assert.Equal(
|
||||||
|
["started", "connected", "characterList", "enteredWorld"],
|
||||||
|
EventNames(ReadStatuses(statusPath)));
|
||||||
Assert.DoesNotContain("fixture-", output.ToString());
|
Assert.DoesNotContain("fixture-", output.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ThrowAfterRegistrationRollsBackEventsAndCollectsContext()
|
||||||
|
{
|
||||||
|
using var temporary = new TemporaryDirectory();
|
||||||
|
string pluginDirectory = InstallFixture(
|
||||||
|
temporary.Path,
|
||||||
|
ThrowingId,
|
||||||
|
"throwing-fixture");
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(pluginDirectory, "throw-after-register"),
|
||||||
|
string.Empty);
|
||||||
|
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||||
|
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||||
|
using var session = new HeadlessSessionHost(
|
||||||
|
Descriptor([ThrowingId], statusPath),
|
||||||
|
credential,
|
||||||
|
new HeadlessDiagnosticWriter(new StringWriter()),
|
||||||
|
new FixtureSessionOperations(),
|
||||||
|
pluginRoots: [temporary.Path]);
|
||||||
|
|
||||||
|
_ = session.Start();
|
||||||
|
Assert.Equal(0, session.Plugins.LoadedCount);
|
||||||
|
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
|
||||||
|
Assert.False(File.Exists(
|
||||||
|
Path.Combine(pluginDirectory, "unexpected-callback")));
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"started", "pluginFailed", "connected", "characterList",
|
||||||
|
"enteredWorld",
|
||||||
|
],
|
||||||
|
EventNames(ReadStatuses(statusPath)));
|
||||||
|
|
||||||
|
WeakReference context = Assert.Single(
|
||||||
|
session.Plugins.CaptureLoadContextWeakReferences());
|
||||||
|
session.Dispose();
|
||||||
|
Collect(context);
|
||||||
|
Assert.False(context.IsAlive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LauncherProbeRoundTripKeepsPluginsDisabledInTheRealHost()
|
||||||
|
{
|
||||||
|
using var temporary = new TemporaryDirectory();
|
||||||
|
InstallFixture(temporary.Path, FixtureId);
|
||||||
|
string configPath = Path.Combine(temporary.Path, "probe.json");
|
||||||
|
File.WriteAllText(
|
||||||
|
configPath,
|
||||||
|
LauncherCoreSessionConfigFixture.ComposeProbe());
|
||||||
|
HeadlessSessionDescriptor descriptor = Assert.Single(
|
||||||
|
HeadlessConfigurationLoader.Load(configPath).Sessions)! with
|
||||||
|
{
|
||||||
|
StatusFile = Path.Combine(temporary.Path, "probe-status.jsonl"),
|
||||||
|
};
|
||||||
|
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||||
|
using var session = new HeadlessSessionHost(
|
||||||
|
descriptor,
|
||||||
|
credential,
|
||||||
|
new HeadlessDiagnosticWriter(new StringWriter()),
|
||||||
|
new FixtureSessionOperations(),
|
||||||
|
pluginRoots: [temporary.Path]);
|
||||||
|
|
||||||
|
RuntimeSessionStartResult result = session.Start();
|
||||||
|
|
||||||
|
Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, result.Status);
|
||||||
|
Assert.NotNull(descriptor.Plugins);
|
||||||
|
Assert.Empty(descriptor.Plugins);
|
||||||
|
Assert.Equal(0, session.Plugins.LoadedCount);
|
||||||
|
Assert.Equal(
|
||||||
|
["started", "connected", "characterList"],
|
||||||
|
EventNames(ReadStatuses(descriptor.StatusFile!)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LateSubscriberReplayQueuesConcurrentRegistrationExactlyOnceInOrder()
|
||||||
|
{
|
||||||
|
using var temporary = new TemporaryDirectory();
|
||||||
|
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||||
|
using var session = new HeadlessSessionHost(
|
||||||
|
Descriptor([], Path.Combine(temporary.Path, "status.jsonl")),
|
||||||
|
credential,
|
||||||
|
new HeadlessDiagnosticWriter(new StringWriter()),
|
||||||
|
new FixtureSessionOperations(),
|
||||||
|
pluginRoots: [temporary.Path]);
|
||||||
|
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
|
||||||
|
HeadlessPluginHost host = session.Plugins.Host;
|
||||||
|
using var replayCaptured = new ManualResetEventSlim();
|
||||||
|
using var releaseReplay = new ManualResetEventSlim();
|
||||||
|
host.ReplayCapturedForTest = () =>
|
||||||
|
{
|
||||||
|
replayCaptured.Set();
|
||||||
|
Assert.True(releaseReplay.Wait(TimeSpan.FromSeconds(10)));
|
||||||
|
};
|
||||||
|
var observed = new List<uint>();
|
||||||
|
Action<WorldEntitySnapshot> handler = snapshot =>
|
||||||
|
{
|
||||||
|
lock (observed)
|
||||||
|
observed.Add(snapshot.Id);
|
||||||
|
};
|
||||||
|
|
||||||
|
Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler);
|
||||||
|
Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10)));
|
||||||
|
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f));
|
||||||
|
releaseReplay.Set();
|
||||||
|
await subscribe.WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
host.Events.EntitySpawned -= handler;
|
||||||
|
|
||||||
|
Assert.Equal([1_000_000u, 1_000_001u], observed);
|
||||||
|
}
|
||||||
|
|
||||||
private static HeadlessSessionDescriptor Descriptor(
|
private static HeadlessSessionDescriptor Descriptor(
|
||||||
List<string> plugins,
|
List<string> plugins,
|
||||||
string statusPath) => new()
|
string statusPath) => new()
|
||||||
|
|
@ -144,15 +269,19 @@ public sealed class HeadlessPluginSessionTests
|
||||||
private static string[] EventNames(IEnumerable<JsonElement> events) =>
|
private static string[] EventNames(IEnumerable<JsonElement> events) =>
|
||||||
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
|
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
|
||||||
|
|
||||||
private static void InstallFixture(string root, string id)
|
private static string InstallFixture(
|
||||||
|
string root,
|
||||||
|
string id,
|
||||||
|
string directoryName = "host-fixture")
|
||||||
{
|
{
|
||||||
string source = FixtureAssemblyPath();
|
string source = FixtureAssemblyPath();
|
||||||
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
||||||
string pluginDirectory = Path.Combine(root, "host-fixture");
|
string pluginDirectory = Path.Combine(root, directoryName);
|
||||||
Directory.CreateDirectory(pluginDirectory);
|
Directory.CreateDirectory(pluginDirectory);
|
||||||
string fileName = Path.GetFileName(source);
|
string fileName = Path.GetFileName(source);
|
||||||
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
||||||
WriteManifest(pluginDirectory, id, fileName);
|
WriteManifest(pluginDirectory, id, fileName);
|
||||||
|
return pluginDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void InstallBrokenPlugin(string root, string id)
|
private static void InstallBrokenPlugin(string root, string id)
|
||||||
|
|
@ -214,6 +343,39 @@ public sealed class HeadlessPluginSessionTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FixtureSessionOperations : ILiveSessionOperations
|
||||||
|
{
|
||||||
|
private static readonly CharacterList.Parsed Characters = new(
|
||||||
|
0u,
|
||||||
|
[new CharacterList.Character(0x50000001u, "Fixture", 0u)],
|
||||||
|
[],
|
||||||
|
1,
|
||||||
|
"account",
|
||||||
|
true,
|
||||||
|
true);
|
||||||
|
|
||||||
|
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||||
|
new(IPAddress.Loopback, port);
|
||||||
|
|
||||||
|
public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint);
|
||||||
|
|
||||||
|
public void Connect(WorldSession session, string user, string password)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public CharacterList.Parsed? GetCharacters(WorldSession session) => Characters;
|
||||||
|
|
||||||
|
public void EnterWorld(WorldSession session, int activeCharacterIndex)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Tick(WorldSession session)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DisposeSession(WorldSession session) => session.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class TemporaryDirectory : IDisposable
|
private sealed class TemporaryDirectory : IDisposable
|
||||||
{
|
{
|
||||||
internal TemporaryDirectory()
|
internal TemporaryDirectory()
|
||||||
|
|
@ -228,8 +390,22 @@ public sealed class HeadlessPluginSessionTests
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (Directory.Exists(Path))
|
for (int attempt = 0; Directory.Exists(Path); attempt++)
|
||||||
Directory.Delete(Path, recursive: true);
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(Path, recursive: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
when (error is IOException or UnauthorizedAccessException
|
||||||
|
&& attempt < 9)
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
Thread.Sleep(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,33 @@ public sealed class SessionConfigurationSharedFixtureTests
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HeadlessReaderPreservesLauncherExplicitEmptyPluginAllowList()
|
||||||
|
{
|
||||||
|
using TemporaryFile file = TemporaryFile.Create(
|
||||||
|
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
|
||||||
|
|
||||||
|
HeadlessSessionDescriptor session = Assert.Single(
|
||||||
|
HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
|
||||||
|
|
||||||
|
Assert.NotNull(session.Plugins);
|
||||||
|
Assert.Empty(session.Plugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HeadlessReaderPreservesProbeLoadNoneAllowList()
|
||||||
|
{
|
||||||
|
using TemporaryFile file = TemporaryFile.Create(
|
||||||
|
LauncherCoreSessionConfigFixture.ComposeProbe());
|
||||||
|
|
||||||
|
HeadlessSessionDescriptor session = Assert.Single(
|
||||||
|
HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
|
||||||
|
|
||||||
|
Assert.Equal(HeadlessSessionMode.Probe, session.Mode);
|
||||||
|
Assert.NotNull(session.Plugins);
|
||||||
|
Assert.Empty(session.Plugins);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
|
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ public sealed class SessionConfigComposerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays()
|
public void EmptyPluginsRemainAnExplicitLoadNoneAllowListWhileLoginCommandsAreOmitted()
|
||||||
{
|
{
|
||||||
CharacterProfile character = Character(LaunchMode.Gui);
|
CharacterProfile character = Character(LaunchMode.Gui);
|
||||||
character.Plugins = [];
|
character.Plugins = [];
|
||||||
|
|
@ -190,7 +190,8 @@ public sealed class SessionConfigComposerTests
|
||||||
sessionId: "session-empty-lists");
|
sessionId: "session-empty-lists");
|
||||||
|
|
||||||
JsonObject session = SingleSession(composed);
|
JsonObject session = SingleSession(composed);
|
||||||
Assert.False(session.ContainsKey("plugins"));
|
Assert.True(session.ContainsKey("plugins"));
|
||||||
|
Assert.Empty(session["plugins"]!.AsArray());
|
||||||
Assert.False(session.ContainsKey("loginCommands"));
|
Assert.False(session.ContainsKey("loginCommands"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,7 +244,7 @@ public sealed class SessionConfigComposerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands()
|
public void ProbeModeSetsModeAndCarriesExplicitLoadNonePluginAllowList()
|
||||||
{
|
{
|
||||||
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
|
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
|
||||||
Server(),
|
Server(),
|
||||||
|
|
@ -256,7 +257,7 @@ public sealed class SessionConfigComposerTests
|
||||||
|
|
||||||
AssertKeys(
|
AssertKeys(
|
||||||
session,
|
session,
|
||||||
"id", "mode", "endpoint", "account", "credential", "statusFile");
|
"id", "mode", "endpoint", "account", "credential", "plugins", "statusFile");
|
||||||
|
|
||||||
Assert.Equal("session-probe", (string?)session["id"]);
|
Assert.Equal("session-probe", (string?)session["id"]);
|
||||||
Assert.Equal("probe", (string?)session["mode"]);
|
Assert.Equal("probe", (string?)session["mode"]);
|
||||||
|
|
@ -266,7 +267,7 @@ public sealed class SessionConfigComposerTests
|
||||||
Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
|
Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
|
||||||
Assert.False(session.ContainsKey("character"));
|
Assert.False(session.ContainsKey("character"));
|
||||||
Assert.False(session.ContainsKey("policy"));
|
Assert.False(session.ContainsKey("policy"));
|
||||||
Assert.False(session.ContainsKey("plugins"));
|
Assert.Empty(session["plugins"]!.AsArray());
|
||||||
Assert.False(session.ContainsKey("loginCommands"));
|
Assert.False(session.ContainsKey("loginCommands"));
|
||||||
Assert.False(session.ContainsKey("loginCommandDelayMs"));
|
Assert.False(session.ContainsKey("loginCommandDelayMs"));
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,17 @@ namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
|
||||||
public sealed class HostPlugin : IAcDreamPlugin
|
public sealed class HostPlugin : IAcDreamPlugin
|
||||||
{
|
{
|
||||||
private IPluginHost? _host;
|
private IPluginHost? _host;
|
||||||
|
private string? _assemblyDirectory;
|
||||||
|
private bool _throwAfterRegistration;
|
||||||
private int _entitiesSeen;
|
private int _entitiesSeen;
|
||||||
|
|
||||||
public void Initialize(IPluginHost host)
|
public void Initialize(IPluginHost host)
|
||||||
{
|
{
|
||||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
|
_assemblyDirectory = Path.GetDirectoryName(
|
||||||
|
typeof(HostPlugin).Assembly.Location);
|
||||||
|
_throwAfterRegistration = File.Exists(
|
||||||
|
Path.Combine(_assemblyDirectory!, "throw-after-register"));
|
||||||
host.Log.Info($"fixture-initialized:hasUi={host.HasUi}");
|
host.Log.Info($"fixture-initialized:hasUi={host.HasUi}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,6 +33,11 @@ public sealed class HostPlugin : IAcDreamPlugin
|
||||||
Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
|
Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
|
||||||
this);
|
this);
|
||||||
host.Events.EntitySpawned += OnEntitySpawned;
|
host.Events.EntitySpawned += OnEntitySpawned;
|
||||||
|
if (_throwAfterRegistration)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"fixture enable failed after registering UI and events");
|
||||||
|
}
|
||||||
host.Log.Info(
|
host.Log.Info(
|
||||||
$"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}");
|
$"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}");
|
||||||
}
|
}
|
||||||
|
|
@ -36,11 +47,24 @@ public sealed class HostPlugin : IAcDreamPlugin
|
||||||
IPluginHost? host = _host;
|
IPluginHost? host = _host;
|
||||||
if (host is null)
|
if (host is null)
|
||||||
return;
|
return;
|
||||||
|
if (_throwAfterRegistration)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"fixture disable intentionally refuses cleanup");
|
||||||
|
}
|
||||||
host.Events.EntitySpawned -= OnEntitySpawned;
|
host.Events.EntitySpawned -= OnEntitySpawned;
|
||||||
host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}");
|
host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}");
|
||||||
_host = null;
|
_host = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnEntitySpawned(WorldEntitySnapshot snapshot) =>
|
private void OnEntitySpawned(WorldEntitySnapshot snapshot)
|
||||||
|
{
|
||||||
_entitiesSeen++;
|
_entitiesSeen++;
|
||||||
|
if (_throwAfterRegistration && _assemblyDirectory is not null)
|
||||||
|
{
|
||||||
|
File.AppendAllText(
|
||||||
|
Path.Combine(_assemblyDirectory, "unexpected-callback"),
|
||||||
|
$"{snapshot.Id}{Environment.NewLine}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,4 +55,66 @@ internal static class LauncherCoreSessionConfigFixture
|
||||||
|
|
||||||
return SessionConfigComposer.Serialize(composed.Document);
|
return SessionConfigComposer.Serialize(composed.Document);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static string ComposeEmptyPlugins()
|
||||||
|
{
|
||||||
|
(ServerProfile server, AccountProfile account,
|
||||||
|
LauncherInstallRecord install, ApplicationPathSet paths) = Inputs();
|
||||||
|
var character = new CharacterProfile
|
||||||
|
{
|
||||||
|
Name = "Composer Character",
|
||||||
|
Id = "0x50000001",
|
||||||
|
LaunchMode = LaunchMode.Headless,
|
||||||
|
Plugins = [],
|
||||||
|
LoginCommands = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
ComposedSessionConfig composed = SessionConfigComposer.Compose(
|
||||||
|
server,
|
||||||
|
account,
|
||||||
|
character,
|
||||||
|
install,
|
||||||
|
paths,
|
||||||
|
"composer-empty-plugins");
|
||||||
|
return SessionConfigComposer.Serialize(composed.Document);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ComposeProbe()
|
||||||
|
{
|
||||||
|
(ServerProfile server, AccountProfile account,
|
||||||
|
LauncherInstallRecord install, ApplicationPathSet paths) = Inputs();
|
||||||
|
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
|
||||||
|
server,
|
||||||
|
account,
|
||||||
|
install,
|
||||||
|
paths,
|
||||||
|
"composer-probe");
|
||||||
|
return SessionConfigComposer.Serialize(composed.Document);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (
|
||||||
|
ServerProfile Server,
|
||||||
|
AccountProfile Account,
|
||||||
|
LauncherInstallRecord Install,
|
||||||
|
ApplicationPathSet Paths) Inputs() =>
|
||||||
|
(
|
||||||
|
new ServerProfile
|
||||||
|
{
|
||||||
|
Name = "Composer Server",
|
||||||
|
Host = "composer.example",
|
||||||
|
Port = 9010,
|
||||||
|
},
|
||||||
|
new AccountProfile
|
||||||
|
{
|
||||||
|
Account = "composer-account",
|
||||||
|
Password = Password,
|
||||||
|
},
|
||||||
|
new LauncherInstallRecord(
|
||||||
|
"composer-dats",
|
||||||
|
"composer-dats/acdream.pak"),
|
||||||
|
new ApplicationPathSet(
|
||||||
|
Path.Combine(Path.GetTempPath(), "composer-config"),
|
||||||
|
Path.Combine(Path.GetTempPath(), "composer-data"),
|
||||||
|
Path.Combine(Path.GetTempPath(), "composer-cache"),
|
||||||
|
LegacyConfigDirectory: null));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue