merge: Campaign LA LA5 - plugin hosting review-closed
This commit is contained in:
commit
5535d0adac
44 changed files with 3009 additions and 195 deletions
|
|
@ -30,6 +30,7 @@
|
|||
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
|
||||
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
|
||||
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
||||
<Project Path="tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj" />
|
||||
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
|
||||
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
|
||||
</Folder>
|
||||
|
|
|
|||
|
|
@ -120,7 +120,16 @@ handlers and controllers translate those intents to `WorldSession`; panels
|
|||
never inspect or construct wire messages.
|
||||
Plugins register retained gameplay markup through the BCL-only
|
||||
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or
|
||||
presentation assemblies. Core `SelectionState` is the sole selected-object owner for world,
|
||||
presentation assemblies. `IPluginHost.HasUi` is the explicit capability edge:
|
||||
the graphical host supplies its retained registry, while no-window hosts
|
||||
return `false` and the BCL-only `NoOpUiRegistry`, which retains no plugin
|
||||
binding. Both hosts use Core's session-scoped discovery/lifetime orchestrator
|
||||
and the same config allow-list semantics (absent loads all; explicit empty
|
||||
loads none). The headless adapter projects entity snapshots on demand from the
|
||||
canonical Runtime view, subscribes to Runtime's ordered events, and borrows the
|
||||
exact Runtime selection owner; it does not mirror gameplay state.
|
||||
|
||||
Core `SelectionState` is the sole selected-object owner for world,
|
||||
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
|
||||
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
|
||||
Temporary pointer modes are separate App orchestration in `InteractionState` and
|
||||
|
|
@ -174,6 +183,9 @@ parallel window-lifecycle map.
|
|||
```
|
||||
src/
|
||||
AcDream.Core/ Layer 2-4: no Vulkan, no Silk.NET, pure logic
|
||||
Plugins/
|
||||
PluginSession.cs -> shared per-host allow-list, failure isolation,
|
||||
status outcome, and collectible ALC lifetime
|
||||
Physics/
|
||||
PhysicsBody.cs -> body state / integration foundation (done)
|
||||
CollisionPrimitives.cs -> retail primitive helpers (partial, active)
|
||||
|
|
@ -304,6 +316,8 @@ src/
|
|||
Configuration/ -> strict versioned process/session config
|
||||
Credentials/ -> redacted env/stdin/owner-only-file providers
|
||||
Hosting/ -> one GameRuntime/session/lease/policy lifetime
|
||||
Plugins/ -> no-window IPluginHost borrowing Runtime/Core;
|
||||
BCL no-op UI and per-session plugin lifetime
|
||||
Policies/ -> typed Runtime-view/command consumers
|
||||
-> references Runtime only; no presentation/backend package
|
||||
-> Slice K complete: portable single/multi-session production host,
|
||||
|
|
@ -314,6 +328,7 @@ src/
|
|||
AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
|
||||
IAcDreamPlugin.cs -> done
|
||||
IPluginHost.cs -> done
|
||||
IUiRegistry.cs -> capability-aware retained/no-op UI contract
|
||||
IGameState.cs -> done
|
||||
IEvents.cs -> done
|
||||
ISelectionService.cs -> done
|
||||
|
|
@ -368,6 +383,7 @@ src/
|
|||
PlayerMovementController.cs -> active movement driver
|
||||
Plugins/
|
||||
AppPluginHost.cs -> done
|
||||
GraphicalPluginSession.cs -> thin shared-session/root/status adapter
|
||||
```
|
||||
|
||||
The 4B2 production SetPosition routes and shared local-controller body remain
|
||||
|
|
|
|||
|
|
@ -155,7 +155,11 @@ Field rules:
|
|||
for gui/guiSelect/probe.
|
||||
- `credential`: always `{ "provider": "standardInput", "reference":
|
||||
"session" }` for launcher-composed configs.
|
||||
- `plugins`/`loginCommands`/`loginCommandDelayMs`/`statusFile`: optional,
|
||||
- `plugins`: absent/null means load all discovered plugins (preserving the
|
||||
developer flow); explicit `[]` means load none. Launcher-composed
|
||||
normal-empty and probe sessions emit `[]` so they cannot load arbitrary
|
||||
machine-local plugins.
|
||||
- `loginCommands`/`loginCommandDelayMs`/`statusFile`: optional,
|
||||
omitted-when-unset (never null, never `[]` for empty). Absent
|
||||
`loginCommandDelayMs` means 500.
|
||||
|
||||
|
|
@ -297,7 +301,9 @@ are backed by Core-owned types already; only `Ui` (`BufferedUiRegistry`) is
|
|||
genuinely App-only. Headless has zero plugin hosting today (confirmed).
|
||||
|
||||
1. Session-config `Plugins` allow-list filters the discovery result on BOTH
|
||||
hosts (absent list = load all, preserving today's dev behavior).
|
||||
hosts (absent/null list = load all, preserving today's dev behavior;
|
||||
explicit `[]` = load none). Launcher-composed normal-empty and probe
|
||||
sessions emit `[]`.
|
||||
2. `HeadlessPluginHost : IPluginHost` in Headless over the same Core-owned
|
||||
`State`/`Events`/`Selection`; `Ui` is an explicit no-op behind a new
|
||||
capability flag on `IPluginHost` (e.g. `HasUi`) so plugins can detect
|
||||
|
|
|
|||
|
|
@ -168,7 +168,9 @@ Hand-editability is a property of the format, not a required workflow.
|
|||
`HeadlessConfiguration` shape extended with:
|
||||
|
||||
- `Plugins: string[]` — plugin names to load from the standard
|
||||
`PluginsDirectory`; hosts load exactly this set.
|
||||
`PluginsDirectory`; absent/null loads all discovered plugins, while an
|
||||
explicit empty array loads none. Launcher-composed normal-empty and probe
|
||||
sessions emit the empty array.
|
||||
- `LoginCommands: string[]` — ordered chat-typed strings.
|
||||
- Graphical host: `Character` selector may be ABSENT → character-select
|
||||
screen instead of auto-enter.
|
||||
|
|
|
|||
|
|
@ -4,11 +4,7 @@ using AcDream.Runtime.Session;
|
|||
namespace AcDream.App.Composition;
|
||||
|
||||
internal sealed record SessionStartDependencies(
|
||||
Action<string> Log,
|
||||
/// <summary>Campaign LA slice LA1: no-op when no statusFile was
|
||||
/// configured.</summary>
|
||||
SessionStatusWriter StatusWriter,
|
||||
string SessionId);
|
||||
Action<string> Log);
|
||||
|
||||
/// <summary>
|
||||
/// Terminal startup phase. Every callback, command target, and frame root is
|
||||
|
|
@ -26,9 +22,6 @@ internal sealed class SessionStartCompositionPhase
|
|||
public void Start(FrameRootResult 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 =
|
||||
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
|
||||
Report(result, _dependencies.Log);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ public sealed class AppPluginHost : IPluginHost
|
|||
Ui = ui;
|
||||
}
|
||||
|
||||
public bool HasUi => true;
|
||||
public IPluginLogger Log { get; }
|
||||
public IGameState State { get; }
|
||||
public IEvents Events { get; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
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
|
||||
/// UiHost tree after construction.
|
||||
/// </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)
|
||||
=> _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()
|
||||
{
|
||||
var copy = _pending.ToArray();
|
||||
_pending.Clear();
|
||||
return copy;
|
||||
lock (_gate)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
99
src/AcDream.App/Plugins/GraphicalPluginSession.cs
Normal file
99
src/AcDream.App/Plugins/GraphicalPluginSession.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.Platform;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Graphical-host composition for one plugin set. The shared
|
||||
/// <see cref="PluginSession"/> owns discovery and collectible lifetimes; this
|
||||
/// adapter supplies the graphical roots and translates outcomes into the
|
||||
/// launcher status stream.
|
||||
/// </summary>
|
||||
internal sealed class GraphicalPluginSession : IDisposable
|
||||
{
|
||||
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,
|
||||
string[] roots,
|
||||
IReadOnlyList<string>? allowList,
|
||||
string sessionId,
|
||||
SessionStatusWriter statusWriter)
|
||||
{
|
||||
_plugins = plugins;
|
||||
_roots = roots;
|
||||
_allowList = allowList;
|
||||
_sessionId = sessionId;
|
||||
_statusWriter = statusWriter;
|
||||
}
|
||||
|
||||
internal int LoadedCount => _plugins.LoadedCount;
|
||||
|
||||
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||
_plugins.CaptureLoadContextWeakReferences();
|
||||
|
||||
internal static GraphicalPluginSession Create(
|
||||
ApplicationPathSet paths,
|
||||
IReadOnlyList<string>? allowList,
|
||||
string sessionId,
|
||||
IPluginHost host,
|
||||
SessionStatusWriter statusWriter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
ArgumentNullException.ThrowIfNull(statusWriter);
|
||||
|
||||
var plugins = new PluginSession(
|
||||
host,
|
||||
status => Report(statusWriter, sessionId, status));
|
||||
return new GraphicalPluginSession(
|
||||
plugins,
|
||||
[
|
||||
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
||||
paths.PluginsDirectory,
|
||||
],
|
||||
allowList,
|
||||
sessionId,
|
||||
statusWriter);
|
||||
}
|
||||
|
||||
internal void Start()
|
||||
{
|
||||
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();
|
||||
|
||||
private static void Report(
|
||||
SessionStatusWriter writer,
|
||||
string sessionId,
|
||||
PluginSessionStatus status)
|
||||
{
|
||||
if (status.Kind == PluginSessionStatusKind.Loaded)
|
||||
{
|
||||
writer.PluginLoaded(sessionId, status.Plugin);
|
||||
return;
|
||||
}
|
||||
|
||||
writer.PluginFailed(
|
||||
sessionId,
|
||||
status.Plugin,
|
||||
status.Error ?? "plugin failed");
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ using AcDream.App.Credentials;
|
|||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Platform;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Plugins;
|
||||
using AcDream.Platform;
|
||||
using Serilog;
|
||||
|
||||
|
|
@ -162,76 +161,16 @@ var host = new AppPluginHost(
|
|||
worldEvents,
|
||||
window.Selection,
|
||||
uiRegistry);
|
||||
|
||||
var loaded = new List<LoadedPlugin>();
|
||||
var loadedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
StringComparer pathComparer =
|
||||
graphicalPlatform.OperatingSystem
|
||||
== GraphicalHostOperatingSystem.Windows
|
||||
? StringComparer.OrdinalIgnoreCase
|
||||
: StringComparer.Ordinal;
|
||||
string[] pluginRoots =
|
||||
[
|
||||
.. new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
||||
applicationPaths.PluginsDirectory,
|
||||
}.Distinct(pathComparer),
|
||||
];
|
||||
|
||||
foreach (string pluginsDir in pluginRoots)
|
||||
{
|
||||
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
||||
foreach (var result in PluginDiscovery.Scan(pluginsDir))
|
||||
{
|
||||
if (!result.Success)
|
||||
{
|
||||
Log.Warning(
|
||||
"plugin discovery failed for {Dir}: {Error}",
|
||||
result.PluginDirectory,
|
||||
result.Error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (loadedPluginIds.Contains(result.Manifest!.Id))
|
||||
{
|
||||
Log.Warning(
|
||||
"skipping duplicate plugin id {Id} from {Dir}",
|
||||
result.Manifest.Id,
|
||||
result.PluginDirectory);
|
||||
continue;
|
||||
}
|
||||
|
||||
var loadResult = PluginLoader.Load(
|
||||
result.PluginDirectory,
|
||||
result.Manifest,
|
||||
host);
|
||||
if (!loadResult.Success)
|
||||
{
|
||||
Log.Warning(
|
||||
"plugin load failed for {Id}: {Error}",
|
||||
result.Manifest.Id,
|
||||
loadResult.Error);
|
||||
continue;
|
||||
}
|
||||
|
||||
loadedPluginIds.Add(result.Manifest.Id);
|
||||
loaded.Add(loadResult);
|
||||
Log.Information(
|
||||
"loaded plugin {Id} ({DisplayName})",
|
||||
result.Manifest.Id,
|
||||
result.Manifest.DisplayName);
|
||||
}
|
||||
}
|
||||
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
|
||||
applicationPaths,
|
||||
runtimeOptions.Plugins,
|
||||
runtimeOptions.SessionId ?? "app",
|
||||
host,
|
||||
window.StatusWriter);
|
||||
window.StartPluginHosting(pluginSession);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var plugin in loaded)
|
||||
{
|
||||
try { plugin.Plugin!.Enable(); }
|
||||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
window.Run();
|
||||
|
|
@ -244,11 +183,6 @@ try
|
|||
}
|
||||
finally
|
||||
{
|
||||
foreach (var plugin in loaded)
|
||||
{
|
||||
try { plugin.Plugin!.Disable(); }
|
||||
catch (Exception ex) { Log.Error(ex, "plugin disable failed: {Id}", plugin.Manifest.Id); }
|
||||
}
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -362,6 +362,7 @@ public sealed class GameWindow :
|
|||
private RuntimeActionState _runtimeActions => _runtime.ActionOwner;
|
||||
public AcDream.Core.Selection.SelectionState Selection =>
|
||||
_runtimeActions.Selection;
|
||||
internal SessionStatusWriter StatusWriter => _statusWriter;
|
||||
public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat;
|
||||
public AcDream.Core.Chat.TurbineChatState TurbineChat =>
|
||||
_runtimeCommunication.TurbineChat;
|
||||
|
|
@ -430,6 +431,7 @@ public sealed class GameWindow :
|
|||
_creatureAppraisalFramePresenter;
|
||||
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
||||
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
||||
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
|
||||
// Campaign V slice V11 deleted the ImGui developer-tools frontend along
|
||||
// with the OpenGL backend it required, so no host ever composes a
|
||||
// developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches
|
||||
|
|
@ -722,6 +724,24 @@ public sealed class GameWindow :
|
|||
_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()
|
||||
{
|
||||
_platformServices.ConfigureWindowBackend();
|
||||
|
|
@ -1554,9 +1574,7 @@ public sealed class GameWindow :
|
|||
sessionPlayer),
|
||||
frameRoots => new SessionStartCompositionPhase(
|
||||
new SessionStartDependencies(
|
||||
Console.WriteLine,
|
||||
_statusWriter,
|
||||
_options.SessionId ?? "app"))
|
||||
Console.WriteLine))
|
||||
.Start(frameRoots));
|
||||
}
|
||||
|
||||
|
|
@ -1703,6 +1721,7 @@ public sealed class GameWindow :
|
|||
_kbSource,
|
||||
_retailUiLease,
|
||||
_uiHost,
|
||||
_pluginSession,
|
||||
_runtime,
|
||||
_movementInput,
|
||||
_cameraInput,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ internal sealed record IngressShutdownRoots(
|
|||
RetailUiRuntimeLease RetailUi,
|
||||
// Keeps failed physical UI bindings alive through native-window release.
|
||||
UiHost? RetainedUiHost,
|
||||
IDisposable? Plugins,
|
||||
GameRuntime Runtime,
|
||||
DispatcherMovementInputSource MovementInput,
|
||||
DispatcherCameraInputSource CameraInput,
|
||||
|
|
@ -422,6 +423,10 @@ internal static class GameWindowShutdownManifest
|
|||
Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)),
|
||||
Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)),
|
||||
]),
|
||||
new ResourceShutdownStage("plugin host",
|
||||
[
|
||||
Hard("plugins", () => ingress.Plugins?.Dispose()),
|
||||
]),
|
||||
new ResourceShutdownStage("frame borrowers",
|
||||
[
|
||||
Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()),
|
||||
|
|
|
|||
|
|
@ -3273,10 +3273,12 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.Controls);
|
||||
Host.Root.AddChild(element);
|
||||
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
|
||||
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_bindings.Plugins.FailMount(panel);
|
||||
Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ namespace AcDream.Core.Plugins;
|
|||
/// Outcome of a plugin load attempt.
|
||||
/// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/>
|
||||
/// owns its assembly, and <see cref="Error"/> is null.</para>
|
||||
/// <para>On failure, <see cref="Plugin"/> and <see cref="LoadContext"/> are null and
|
||||
/// <see cref="Error"/> describes what went wrong.</para>
|
||||
/// <para>On failure, <see cref="Error"/> describes what went wrong. A partial
|
||||
/// <see cref="Plugin"/> and/or <see cref="LoadContext"/> may still be present;
|
||||
/// the caller owns their cleanup. The loader never requests collectible unload
|
||||
/// itself because the session must first roll back host registrations.</para>
|
||||
/// </summary>
|
||||
public sealed record LoadedPlugin(
|
||||
PluginManifest Manifest,
|
||||
|
|
@ -16,5 +18,6 @@ public sealed record LoadedPlugin(
|
|||
AssemblyLoadContext? LoadContext,
|
||||
Exception? Error)
|
||||
{
|
||||
public bool Success => Plugin is not null && Error is null;
|
||||
public bool Success =>
|
||||
Plugin is not null && LoadContext is not null && Error is null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,15 @@ public static class PluginLoader
|
|||
/// implementing <see cref="IAcDreamPlugin"/>, instantiate it, and call its
|
||||
/// <see cref="IAcDreamPlugin.Initialize"/> with the supplied host. Any failure
|
||||
/// is returned as a failed <see cref="LoadedPlugin"/> rather than thrown.
|
||||
/// A returned partial plugin/context remains caller-owned; this method never
|
||||
/// requests unload because the caller must close host registrations first.
|
||||
/// </summary>
|
||||
public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
|
||||
var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll);
|
||||
if (!File.Exists(dllPath))
|
||||
return new LoadedPlugin(
|
||||
|
|
@ -22,9 +28,11 @@ public static class PluginLoader
|
|||
LoadContext: null,
|
||||
Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath));
|
||||
|
||||
PluginAssemblyLoadContext? alc = null;
|
||||
IAcDreamPlugin? instance = null;
|
||||
try
|
||||
{
|
||||
var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
|
||||
alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
|
||||
var asm = alc.LoadFromAssemblyPath(dllPath);
|
||||
|
||||
IEnumerable<Type> types;
|
||||
|
|
@ -41,20 +49,30 @@ public static class PluginLoader
|
|||
.FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t));
|
||||
|
||||
if (pluginType is null)
|
||||
{
|
||||
return new LoadedPlugin(
|
||||
manifest,
|
||||
Plugin: null,
|
||||
LoadContext: null,
|
||||
LoadContext: alc,
|
||||
Error: new InvalidOperationException(
|
||||
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
|
||||
}
|
||||
|
||||
var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
|
||||
instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
|
||||
instance.Initialize(host);
|
||||
return new LoadedPlugin(manifest, instance, alc, Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex);
|
||||
// The caller owns rollback for a partial instance/context. In
|
||||
// particular, Initialize may already have attached host callbacks;
|
||||
// the per-plugin host scope must remove those registrations before
|
||||
// Disable or any collectible unload request can run.
|
||||
return new LoadedPlugin(
|
||||
manifest,
|
||||
Plugin: instance,
|
||||
LoadContext: alc,
|
||||
Error: ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
421
src/AcDream.Core/Plugins/PluginSession.cs
Normal file
421
src/AcDream.Core/Plugins/PluginSession.cs
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
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);
|
||||
}
|
||||
272
src/AcDream.Core/Plugins/ScopedPluginHost.cs
Normal file
272
src/AcDream.Core/Plugins/ScopedPluginHost.cs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Per-plugin host view that owns every registration made through the public
|
||||
/// event/selection/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 ScopedSelectionService _selection;
|
||||
private readonly ScopedUiRegistry _ui;
|
||||
private bool _disposed;
|
||||
|
||||
internal ScopedPluginHost(IPluginHost inner)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_events = new ScopedEvents(inner.Events);
|
||||
_selection = new ScopedSelectionService(inner.Selection);
|
||||
_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 => _selection;
|
||||
public IUiRegistry Ui => _ui;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_events.Dispose();
|
||||
_selection.Dispose();
|
||||
_ui.Dispose();
|
||||
}
|
||||
|
||||
private sealed class ScopedSelectionService(ISelectionService inner)
|
||||
: ISelectionService,
|
||||
IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<Action<SelectionChangedEvent>> _registrations = [];
|
||||
private bool _disposed;
|
||||
|
||||
public uint? SelectedObjectId => inner.SelectedObjectId;
|
||||
public uint? PreviousObjectId => inner.PreviousObjectId;
|
||||
|
||||
public event Action<SelectionChangedEvent> Changed
|
||||
{
|
||||
add
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
try
|
||||
{
|
||||
inner.Changed += value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { inner.Changed -= value; }
|
||||
catch { }
|
||||
throw;
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_registrations.Add(value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try { inner.Changed -= value; }
|
||||
catch { }
|
||||
throw new ObjectDisposedException(nameof(ScopedSelectionService));
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
inner.Changed -= value;
|
||||
lock (_gate)
|
||||
RemoveLast(value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Select(uint objectId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return inner.Select(objectId);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Clear()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return inner.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Action<SelectionChangedEvent>[] registrations;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
registrations = _registrations.ToArray();
|
||||
_registrations.Clear();
|
||||
}
|
||||
|
||||
for (int index = registrations.Length - 1; index >= 0; index--)
|
||||
{
|
||||
try { inner.Changed -= registrations[index]; }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveLast(Action<SelectionChangedEvent> handler)
|
||||
{
|
||||
for (int index = _registrations.Count - 1; index >= 0; index--)
|
||||
{
|
||||
if (_registrations[index] != handler)
|
||||
continue;
|
||||
_registrations.RemoveAt(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,11 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
paths.ConfigDirectory);
|
||||
var sessions = new List<HeadlessSessionHost>(
|
||||
configuration.Sessions.Count);
|
||||
string[] pluginRoots =
|
||||
[
|
||||
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
||||
paths.PluginsDirectory,
|
||||
];
|
||||
HeadlessProcessContentOwner? content = null;
|
||||
HeadlessProcessResourceSampler? resources = null;
|
||||
// FA6: constructed unconditionally — cheap, and every non-gate
|
||||
|
|
@ -104,7 +109,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
sessionOperations,
|
||||
timeProvider,
|
||||
contentLease: contentLease,
|
||||
gateCoordinator: gateCoordinator));
|
||||
gateCoordinator: gateCoordinator,
|
||||
pluginRoots: pluginRoots));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.Headless.Configuration;
|
||||
using AcDream.Headless.Credentials;
|
||||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Plugins;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -155,6 +156,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private readonly IDisposable _hostLease;
|
||||
private readonly IHeadlessBotPolicy _policy;
|
||||
private readonly IDisposable _policySubscription;
|
||||
private readonly HeadlessPluginSession _pluginSession;
|
||||
private readonly LiveSessionHost _liveSession;
|
||||
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
|
||||
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
||||
|
|
@ -238,7 +240,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
contentLease = null,
|
||||
IHeadlessBotPolicy? policyOverride = null,
|
||||
IRuntimePlacementProjectionSink? placementSinkOverride = null,
|
||||
FellowshipAllegianceGateCoordinator? gateCoordinator = null)
|
||||
FellowshipAllegianceGateCoordinator? gateCoordinator = null,
|
||||
IEnumerable<string>? pluginRoots = null)
|
||||
{
|
||||
_descriptor = descriptor
|
||||
?? throw new ArgumentNullException(nameof(descriptor));
|
||||
|
|
@ -263,6 +266,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
IDisposable? hostLease = null;
|
||||
IHeadlessBotPolicy? policy = null;
|
||||
IDisposable? policySubscription = null;
|
||||
HeadlessPluginSession? pluginSession = null;
|
||||
try
|
||||
{
|
||||
var gameplay = new HeadlessGameplayOperations();
|
||||
|
|
@ -297,6 +301,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
// descriptor.StatusFile is unset — every call site below stays
|
||||
// unconditional.
|
||||
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
|
||||
pluginSession = HeadlessPluginSession.Create(
|
||||
runtime,
|
||||
diagnostics,
|
||||
statusWriter,
|
||||
descriptor.Id,
|
||||
pluginRoots ?? [],
|
||||
descriptor.Plugins);
|
||||
var liveSession = new LiveSessionHost(
|
||||
runtime.Session,
|
||||
new LiveSessionHostBindings(
|
||||
|
|
@ -402,9 +413,11 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_hostLease = hostLease;
|
||||
_policy = policy;
|
||||
_policySubscription = policySubscription;
|
||||
_pluginSession = pluginSession;
|
||||
}
|
||||
catch
|
||||
{
|
||||
pluginSession?.Dispose();
|
||||
policySubscription?.Dispose();
|
||||
policy?.Dispose();
|
||||
hostLease?.Dispose();
|
||||
|
|
@ -427,6 +440,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
/// production code uses to reach the same state.
|
||||
/// </summary>
|
||||
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
|
||||
internal HeadlessPluginSession Plugins => _pluginSession;
|
||||
internal string SessionId => _descriptor.Id;
|
||||
internal string ActiveCharacterName { get; private set; } =
|
||||
string.Empty;
|
||||
|
|
@ -474,6 +488,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
// Campaign LA slice LA1: "started" = session host start — the
|
||||
// earliest point this session actually attempts to connect.
|
||||
_statusWriter.Started(_descriptor.Id);
|
||||
_pluginSession.Start();
|
||||
RuntimeSessionStartResult result =
|
||||
Commands.Session.Start(Runtime.Generation);
|
||||
_startOutcome = result.Status;
|
||||
|
|
@ -638,22 +653,26 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_disposeStage++;
|
||||
break;
|
||||
case 4:
|
||||
_hostLease.Dispose();
|
||||
_pluginSession.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 5:
|
||||
_credential.Dispose();
|
||||
_hostLease.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 6:
|
||||
Runtime.Dispose();
|
||||
_credential.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 7:
|
||||
_contentLease?.Dispose();
|
||||
Runtime.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 8:
|
||||
_contentLease?.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 9:
|
||||
_diagnostics.Message(
|
||||
_descriptor.Id,
|
||||
"disposed",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ internal sealed record HeadlessPathSet(
|
|||
string DataDirectory,
|
||||
string CacheDirectory)
|
||||
{
|
||||
internal string PluginsDirectory =>
|
||||
Path.Combine(DataDirectory, "plugins");
|
||||
|
||||
internal static HeadlessPathSet Resolve(
|
||||
HeadlessPathOverrides overrides,
|
||||
IHeadlessPlatformEnvironment? platform = null)
|
||||
|
|
|
|||
265
src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
Normal file
265
src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.Headless.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// No-window plugin surface over one exact <see cref="GameRuntime"/>. State is
|
||||
/// projected on demand from Runtime's canonical entity view, events come from
|
||||
/// Runtime's ordered event source, and selection is the exact J5 action owner;
|
||||
/// this adapter owns no gameplay mirror.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessPluginHost
|
||||
: IPluginHost,
|
||||
IGameState,
|
||||
IEvents,
|
||||
IRuntimeEventObserver,
|
||||
IDisposable
|
||||
{
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly IDisposable _eventSubscription;
|
||||
private readonly object _eventGate = new();
|
||||
private readonly List<Subscription> _subscriptions = [];
|
||||
private Subscription[] _liveSnapshot = [];
|
||||
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(
|
||||
GameRuntime runtime,
|
||||
IPluginLogger logger)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
Log = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventSubscription = runtime.Subscribe(this);
|
||||
}
|
||||
|
||||
public bool HasUi => false;
|
||||
public IPluginLogger Log { get; }
|
||||
public IGameState State => this;
|
||||
public IEvents Events => this;
|
||||
public ISelectionService Selection => _runtime.ActionOwner.Selection;
|
||||
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||
|
||||
/// <summary>Test-only barrier invoked after the first replay item is
|
||||
/// captured while Runtime's exact active-membership read lease is still
|
||||
/// held.</summary>
|
||||
internal Action? ReplayCapturedForTest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Immutable point-in-time values produced directly from Runtime on each
|
||||
/// read. The caller owns the returned snapshot list; this host retains no
|
||||
/// entity collection and therefore cannot become a second gameplay owner.
|
||||
/// </summary>
|
||||
public IReadOnlyList<WorldEntitySnapshot> Entities
|
||||
{
|
||||
get
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var visitor = new SnapshotVisitor(_runtime);
|
||||
_runtime.Entities.Visit(visitor);
|
||||
return visitor.Items.Select(static item => item.Snapshot).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||
{
|
||||
add
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var subscription = new Subscription(value);
|
||||
lock (_eventGate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_subscriptions.Add(subscription);
|
||||
}
|
||||
|
||||
// Arm the pending queue before borrowing Runtime's snapshot. This
|
||||
// avoids a host-lock/Runtime-lock inversion while the identity
|
||||
// dedup below collapses any registration present in both views.
|
||||
var visitor = new SnapshotVisitor(
|
||||
_runtime,
|
||||
ReplayCapturedForTest);
|
||||
_runtime.Entities.Visit(visitor);
|
||||
ReplayEntity[] replay = visitor.Items.ToArray();
|
||||
|
||||
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
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
lock (_eventGate)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
lock (_eventGate)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
if (delta.Change != RuntimeEntityChange.Registered)
|
||||
return;
|
||||
Subscription[] toNotify;
|
||||
var pending = new ReplayEntity(
|
||||
delta.Entity.Identity,
|
||||
Convert(_runtime, delta.Entity));
|
||||
lock (_eventGate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
foreach (Subscription subscription in _subscriptions)
|
||||
{
|
||||
if (subscription.Active && subscription.Replaying)
|
||||
subscription.Pending.Enqueue(pending);
|
||||
}
|
||||
toNotify = _liveSnapshot;
|
||||
}
|
||||
if (toNotify.Length == 0)
|
||||
return;
|
||||
|
||||
foreach (Subscription subscription in toNotify)
|
||||
Invoke(subscription.Handler, pending.Snapshot);
|
||||
}
|
||||
|
||||
public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
|
||||
public void OnCommand(in RuntimeCommandDelta delta) { }
|
||||
public void OnInventory(in RuntimeInventoryDelta delta) { }
|
||||
public void OnChat(in RuntimeChatDelta delta) { }
|
||||
public void OnMovement(in RuntimeMovementDelta delta) { }
|
||||
public void OnPortal(in RuntimePortalDelta delta) { }
|
||||
public void OnCombat(in RuntimeCombatDelta delta) { }
|
||||
|
||||
private static WorldEntitySnapshot Convert(
|
||||
GameRuntime runtime,
|
||||
in RuntimeEntitySnapshot entity)
|
||||
{
|
||||
uint sourceId = runtime.EntityObjects.Entities.TryGetActive(
|
||||
entity.Identity.ServerGuid,
|
||||
out AcDream.Runtime.Entities.RuntimeEntityRecord record)
|
||||
? record.Snapshot.SetupTableId ?? 0u
|
||||
: 0u;
|
||||
return new WorldEntitySnapshot(
|
||||
entity.Identity.LocalEntityId,
|
||||
sourceId,
|
||||
entity.Position?.Frame.Origin ?? default,
|
||||
entity.Position?.Frame.Orientation
|
||||
?? System.Numerics.Quaternion.Identity);
|
||||
}
|
||||
|
||||
private static void Invoke(
|
||||
Action<WorldEntitySnapshot> handler,
|
||||
WorldEntitySnapshot snapshot)
|
||||
{
|
||||
try { handler(snapshot); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private sealed class SnapshotVisitor(
|
||||
GameRuntime runtime,
|
||||
Action? captureBarrier = null)
|
||||
: IRuntimeEntityVisitor
|
||||
{
|
||||
private Action? _captureBarrier = captureBarrier;
|
||||
|
||||
internal List<ReplayEntity> Items { get; } =
|
||||
new(runtime.Entities.Count);
|
||||
|
||||
public void Visit(in RuntimeEntitySnapshot entity)
|
||||
{
|
||||
Items.Add(new ReplayEntity(
|
||||
entity.Identity,
|
||||
Convert(runtime, entity)));
|
||||
Interlocked.Exchange(ref _captureBarrier, null)?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildLiveSnapshotLocked()
|
||||
{
|
||||
_liveSnapshot = _subscriptions
|
||||
.Where(static subscription =>
|
||||
subscription.Active && !subscription.Replaying)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
43
src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs
Normal file
43
src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Headless.Plugins;
|
||||
|
||||
internal sealed class HeadlessPluginLogger : IPluginLogger
|
||||
{
|
||||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||
private readonly string _sessionId;
|
||||
private readonly Func<ulong> _generation;
|
||||
|
||||
internal HeadlessPluginLogger(
|
||||
HeadlessDiagnosticWriter diagnostics,
|
||||
string sessionId,
|
||||
Func<ulong> generation)
|
||||
{
|
||||
_diagnostics = diagnostics
|
||||
?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_sessionId = sessionId
|
||||
?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_generation = generation
|
||||
?? throw new ArgumentNullException(nameof(generation));
|
||||
}
|
||||
|
||||
public void Info(string message) => Write("info", message);
|
||||
public void Warn(string message) => Write("warn", message);
|
||||
|
||||
public void Error(string message, Exception? exception = null)
|
||||
{
|
||||
if (exception is not null)
|
||||
{
|
||||
_diagnostics.Failure(_sessionId, "plugin", exception);
|
||||
return;
|
||||
}
|
||||
Write("error", message);
|
||||
}
|
||||
|
||||
private void Write(string level, string message) =>
|
||||
_diagnostics.Message(
|
||||
_sessionId,
|
||||
$"plugin-{level}:{message}",
|
||||
_generation());
|
||||
}
|
||||
120
src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
Normal file
120
src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Headless.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Headless composition wrapper that keeps plugin disable/unsubscribe/unload
|
||||
/// ahead of canonical Runtime disposal.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessPluginSession : IDisposable
|
||||
{
|
||||
private readonly HeadlessPluginHost _host;
|
||||
private readonly PluginSession _plugins;
|
||||
private readonly string[] _roots;
|
||||
private readonly IReadOnlyList<string>? _allowList;
|
||||
private int _disposeStage;
|
||||
private bool _started;
|
||||
private bool _disposed;
|
||||
|
||||
private HeadlessPluginSession(
|
||||
HeadlessPluginHost host,
|
||||
PluginSession plugins,
|
||||
string[] roots,
|
||||
IReadOnlyList<string>? allowList)
|
||||
{
|
||||
_host = host;
|
||||
_plugins = plugins;
|
||||
_roots = roots;
|
||||
_allowList = allowList;
|
||||
}
|
||||
|
||||
internal int LoadedCount => _plugins.LoadedCount;
|
||||
internal HeadlessPluginHost Host => _host;
|
||||
|
||||
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||
_plugins.CaptureLoadContextWeakReferences();
|
||||
|
||||
internal static HeadlessPluginSession Create(
|
||||
GameRuntime runtime,
|
||||
HeadlessDiagnosticWriter diagnostics,
|
||||
SessionStatusWriter statusWriter,
|
||||
string sessionId,
|
||||
IEnumerable<string> roots,
|
||||
IReadOnlyList<string>? allowList)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(diagnostics);
|
||||
ArgumentNullException.ThrowIfNull(statusWriter);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
ArgumentNullException.ThrowIfNull(roots);
|
||||
|
||||
var host = new HeadlessPluginHost(
|
||||
runtime,
|
||||
new HeadlessPluginLogger(
|
||||
diagnostics,
|
||||
sessionId,
|
||||
() => runtime.Generation.Value));
|
||||
var plugins = new PluginSession(
|
||||
host,
|
||||
status => Report(statusWriter, sessionId, status));
|
||||
return new HeadlessPluginSession(
|
||||
host,
|
||||
plugins,
|
||||
roots.ToArray(),
|
||||
allowList);
|
||||
}
|
||||
|
||||
internal void Start()
|
||||
{
|
||||
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()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
while (!_disposed)
|
||||
{
|
||||
switch (_disposeStage)
|
||||
{
|
||||
case 0:
|
||||
_plugins.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 1:
|
||||
_host.Dispose();
|
||||
_disposeStage++;
|
||||
_disposed = true;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
"Unknown headless plugin teardown stage.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Report(
|
||||
SessionStatusWriter writer,
|
||||
string sessionId,
|
||||
PluginSessionStatus status)
|
||||
{
|
||||
if (status.Kind == PluginSessionStatusKind.Loaded)
|
||||
{
|
||||
writer.PluginLoaded(sessionId, status.Plugin);
|
||||
return;
|
||||
}
|
||||
writer.PluginFailed(
|
||||
sessionId,
|
||||
status.Plugin,
|
||||
status.Error ?? "plugin failed");
|
||||
}
|
||||
}
|
||||
|
|
@ -132,7 +132,9 @@ public static class SessionConfigComposer
|
|||
Character = selector,
|
||||
Policy = policy,
|
||||
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
|
||||
? [.. character.LoginCommands]
|
||||
: null,
|
||||
|
|
@ -165,9 +167,9 @@ public static class SessionConfigComposer
|
|||
/// §LA3 review finding F2): the session carries <c>mode: "probe"</c>,
|
||||
/// no <c>character</c> selector, and no <c>policy</c> — the host
|
||||
/// reports the account's character roster over the status stream and
|
||||
/// exits without entering the world. <c>plugins</c>/<c>loginCommands</c>
|
||||
/// don't apply to a probe and are always omitted, exactly like an
|
||||
/// empty configured set on a normal session.
|
||||
/// exits without entering the world. Probes carry an explicit empty
|
||||
/// <c>plugins</c> allow-list so a plugin installed on the machine cannot
|
||||
/// run merely because the probe has no character-level plugin settings.
|
||||
/// </summary>
|
||||
public static ComposedSessionConfig ComposeProbe(
|
||||
ServerProfile server,
|
||||
|
|
@ -197,7 +199,7 @@ public static class SessionConfigComposer
|
|||
Character = null,
|
||||
Policy = null,
|
||||
Credential = new SessionCredentialDescriptor(),
|
||||
Plugins = null,
|
||||
Plugins = [],
|
||||
LoginCommands = null,
|
||||
LoginCommandDelayMs = null,
|
||||
StatusFile = statusFilePath,
|
||||
|
|
|
|||
|
|
@ -98,8 +98,10 @@ public sealed class SessionDescriptor
|
|||
|
||||
public SessionCredentialDescriptor Credential { get; init; } = new();
|
||||
|
||||
/// <summary>Omitted (never an empty array) when the character has no
|
||||
/// configured plugin set.</summary>
|
||||
/// <summary>Plugin allow-list. Omitted or JSON <c>null</c> means load all
|
||||
/// discovered plugins (the developer flow); an explicit empty array means
|
||||
/// load none. Launcher-composed normal-empty and probe sessions therefore
|
||||
/// emit <c>[]</c>.</summary>
|
||||
public List<string>? Plugins { get; init; }
|
||||
|
||||
/// <summary>Omitted (never an empty array) when the character has no
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ namespace AcDream.Plugin.Abstractions;
|
|||
/// </summary>
|
||||
public interface IPluginHost
|
||||
{
|
||||
/// <summary>
|
||||
/// <see langword="true"/> when <see cref="Ui"/> registrations can be
|
||||
/// projected by this host. No-window hosts return <see langword="false"/>
|
||||
/// and expose <see cref="NoOpUiRegistry.Instance"/> so a plugin may keep
|
||||
/// one code path while deliberately omitting presentation work.
|
||||
/// </summary>
|
||||
bool HasUi { get; }
|
||||
|
||||
IPluginLogger Log { get; }
|
||||
IGameState State { get; }
|
||||
IEvents Events { get; }
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ namespace AcDream.Plugin.Abstractions;
|
|||
/// <summary>
|
||||
/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +
|
||||
/// a binding object exposing the data properties the markup binds to, and
|
||||
/// registers it from <c>Enable()</c>. Calls made before the GL window opens are
|
||||
/// buffered and drained once the UI host exists.
|
||||
/// registers it from <c>Enable()</c>. Graphical hosts buffer registrations until
|
||||
/// their retained UI exists. A host whose <see cref="IPluginHost.HasUi"/> is
|
||||
/// <see langword="false"/> exposes <see cref="NoOpUiRegistry.Instance"/> and
|
||||
/// intentionally discards registrations.
|
||||
/// </summary>
|
||||
public interface IUiRegistry
|
||||
{
|
||||
|
|
@ -12,3 +14,45 @@ public interface IUiRegistry
|
|||
/// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param>
|
||||
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>
|
||||
/// BCL-only UI sink for no-window plugin hosts. It intentionally retains
|
||||
/// neither markup paths nor binding objects, so a UI registration cannot keep
|
||||
/// a plugin assembly alive after its collectible load context is unloaded.
|
||||
/// </summary>
|
||||
public sealed class NoOpUiRegistry : IScopedUiRegistry
|
||||
{
|
||||
public static NoOpUiRegistry Instance { get; } = new();
|
||||
|
||||
private NoOpUiRegistry()
|
||||
{
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public sealed class RuntimeEntityDirectory
|
|||
public const uint LastLocalEntityId = 0x3FFF_FFFFu;
|
||||
|
||||
private readonly InboundPhysicsStateController _inbound = new();
|
||||
private readonly object _activeGate = new();
|
||||
private readonly Dictionary<uint, RuntimeEntityRecord> _activeByGuid = new();
|
||||
private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord>
|
||||
_teardownByIncarnation = new();
|
||||
|
|
@ -35,10 +36,27 @@ public sealed class RuntimeEntityDirectory
|
|||
_nextLocalEntityId = firstLocalEntityId;
|
||||
}
|
||||
|
||||
public int Count => _activeByGuid.Count;
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_activeGate)
|
||||
return _activeByGuid.Count;
|
||||
}
|
||||
}
|
||||
public int PendingTeardownCount => _teardownByIncarnation.Count;
|
||||
public int ClaimedLocalIdCount => _byLocalId.Count;
|
||||
public int ClaimedLocalIdCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_activeGate)
|
||||
return _byLocalId.Count;
|
||||
}
|
||||
}
|
||||
public ulong SessionLifetimeVersion { get; private set; }
|
||||
/// <summary>Update-thread-only borrowed collection. Cross-thread hosts use
|
||||
/// <see cref="AcquireActiveRead"/> through <c>IRuntimeEntityView.Visit</c>
|
||||
/// so membership cannot change during enumeration.</summary>
|
||||
public IReadOnlyCollection<RuntimeEntityRecord> ActiveRecords => _activeByGuid.Values;
|
||||
public IReadOnlyCollection<RuntimeEntityRecord> TeardownRecords =>
|
||||
_teardownByIncarnation.Values;
|
||||
|
|
@ -87,34 +105,48 @@ public sealed class RuntimeEntityDirectory
|
|||
|
||||
public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot)
|
||||
{
|
||||
if (_activeByGuid.ContainsKey(snapshot.Guid))
|
||||
lock (_activeGate)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{snapshot.Guid:X8} already has an active incarnation.");
|
||||
}
|
||||
if (_activeByGuid.ContainsKey(snapshot.Guid))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{snapshot.Guid:X8} already has an active incarnation.");
|
||||
}
|
||||
|
||||
var record = new RuntimeEntityRecord(snapshot);
|
||||
_activeByGuid.Add(snapshot.Guid, record);
|
||||
try
|
||||
{
|
||||
ClaimLocalId(record);
|
||||
return record;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_activeByGuid.Remove(snapshot.Guid);
|
||||
throw;
|
||||
var record = new RuntimeEntityRecord(snapshot);
|
||||
_activeByGuid.Add(snapshot.Guid, record);
|
||||
try
|
||||
{
|
||||
ClaimLocalId(record);
|
||||
return record;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_activeByGuid.Remove(snapshot.Guid);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) =>
|
||||
_activeByGuid.Remove(guid, out record);
|
||||
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record)
|
||||
{
|
||||
lock (_activeGate)
|
||||
return _activeByGuid.Remove(guid, out record);
|
||||
}
|
||||
|
||||
public bool RemoveActive(RuntimeEntityRecord expected)
|
||||
{
|
||||
if (!IsCurrent(expected))
|
||||
return false;
|
||||
return _activeByGuid.Remove(expected.ServerGuid);
|
||||
lock (_activeGate)
|
||||
{
|
||||
if (!_activeByGuid.TryGetValue(
|
||||
expected.ServerGuid,
|
||||
out RuntimeEntityRecord? current)
|
||||
|| !ReferenceEquals(current, expected))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return _activeByGuid.Remove(expected.ServerGuid);
|
||||
}
|
||||
}
|
||||
|
||||
public void RetainTeardown(RuntimeEntityRecord record)
|
||||
|
|
@ -157,46 +189,52 @@ public sealed class RuntimeEntityDirectory
|
|||
|
||||
public uint ClaimLocalId(RuntimeEntityRecord record)
|
||||
{
|
||||
if (!IsKnown(record))
|
||||
lock (_activeGate)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A local id can only be claimed for an active or retained incarnation.");
|
||||
if (!IsKnown(record))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A local id can only be claimed for an active or retained incarnation.");
|
||||
}
|
||||
|
||||
if (record.LocalEntityId is { } existing)
|
||||
return existing;
|
||||
|
||||
uint start = _nextLocalEntityId;
|
||||
do
|
||||
{
|
||||
uint candidate = _nextLocalEntityId;
|
||||
_nextLocalEntityId = candidate == LastLocalEntityId
|
||||
? FirstLocalEntityId
|
||||
: candidate + 1u;
|
||||
if (_byLocalId.ContainsKey(candidate))
|
||||
continue;
|
||||
|
||||
_byLocalId.Add(candidate, record);
|
||||
record.LocalEntityId = candidate;
|
||||
return candidate;
|
||||
}
|
||||
while (_nextLocalEntityId != start);
|
||||
|
||||
throw new InvalidOperationException("The live entity id namespace is exhausted.");
|
||||
}
|
||||
|
||||
if (record.LocalEntityId is { } existing)
|
||||
return existing;
|
||||
|
||||
uint start = _nextLocalEntityId;
|
||||
do
|
||||
{
|
||||
uint candidate = _nextLocalEntityId;
|
||||
_nextLocalEntityId = candidate == LastLocalEntityId
|
||||
? FirstLocalEntityId
|
||||
: candidate + 1u;
|
||||
if (_byLocalId.ContainsKey(candidate))
|
||||
continue;
|
||||
|
||||
_byLocalId.Add(candidate, record);
|
||||
record.LocalEntityId = candidate;
|
||||
return candidate;
|
||||
}
|
||||
while (_nextLocalEntityId != start);
|
||||
|
||||
throw new InvalidOperationException("The live entity id namespace is exhausted.");
|
||||
}
|
||||
|
||||
public bool ReleaseLocalId(RuntimeEntityRecord record)
|
||||
{
|
||||
if (record.LocalEntityId is not { } localId)
|
||||
return false;
|
||||
if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained)
|
||||
&& ReferenceEquals(retained, record))
|
||||
lock (_activeGate)
|
||||
{
|
||||
_byLocalId.Remove(localId);
|
||||
}
|
||||
if (record.LocalEntityId is not { } localId)
|
||||
return false;
|
||||
if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained)
|
||||
&& ReferenceEquals(retained, record))
|
||||
{
|
||||
_byLocalId.Remove(localId);
|
||||
}
|
||||
|
||||
record.LocalEntityId = null;
|
||||
return true;
|
||||
record.LocalEntityId = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public ulong AdvanceLifetimeMutation(uint serverGuid)
|
||||
|
|
@ -219,15 +257,39 @@ public sealed class RuntimeEntityDirectory
|
|||
|
||||
public bool CompleteSessionClearIfConverged()
|
||||
{
|
||||
if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0)
|
||||
return false;
|
||||
lock (_activeGate)
|
||||
{
|
||||
if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0)
|
||||
return false;
|
||||
|
||||
_byLocalId.Clear();
|
||||
_byLocalId.Clear();
|
||||
}
|
||||
ParentAttachments.Clear();
|
||||
_inbound.Clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enters the exact active-membership read boundary. Add/remove and local-id
|
||||
/// membership commits serialize behind this allocation-free lease; record
|
||||
/// ownership remains canonical here and no copied gameplay collection is
|
||||
/// introduced.
|
||||
/// </summary>
|
||||
internal ActiveReadLease AcquireActiveRead() => new(_activeGate);
|
||||
|
||||
internal readonly struct ActiveReadLease : IDisposable
|
||||
{
|
||||
private readonly object _gate;
|
||||
|
||||
internal ActiveReadLease(object gate)
|
||||
{
|
||||
_gate = gate;
|
||||
Monitor.Enter(gate);
|
||||
}
|
||||
|
||||
public void Dispose() => Monitor.Exit(_gate);
|
||||
}
|
||||
|
||||
public void RefreshSnapshot(
|
||||
RuntimeEntityRecord record,
|
||||
WorldSession.EntitySpawn accepted,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ internal sealed class RuntimeEntityObjectViews
|
|||
uint serverGuid,
|
||||
out RuntimeEntitySnapshot entity)
|
||||
{
|
||||
using RuntimeEntityDirectory.ActiveReadLease lease =
|
||||
owner.AcquireActiveRead();
|
||||
if (owner.TryGetActive(
|
||||
serverGuid,
|
||||
out RuntimeEntityRecord record))
|
||||
|
|
@ -106,6 +108,8 @@ internal sealed class RuntimeEntityObjectViews
|
|||
public void Visit(IRuntimeEntityVisitor visitor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(visitor);
|
||||
using RuntimeEntityDirectory.ActiveReadLease lease =
|
||||
owner.AcquireActiveRead();
|
||||
foreach (RuntimeEntityRecord record in owner.ActiveRecords)
|
||||
{
|
||||
RuntimeEntitySnapshot entity = Snapshot(record);
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ namespace AcDream.Runtime.Session;
|
|||
/// is a single shared-stdout JSONL diagnostics stream with no per-session
|
||||
/// file; this class writes one file per session, meant to be read by an
|
||||
/// external process (the launcher) rather than scraped from console output.
|
||||
/// Event shapes are versioned (<c>"v":1</c>) so a future event kind
|
||||
/// (<c>pluginLoaded</c>/<c>pluginFailed</c>, LA5) can be added without
|
||||
/// breaking an existing reader.
|
||||
/// Event shapes are versioned (<c>"v":1</c>); LA5's
|
||||
/// <c>pluginLoaded</c>/<c>pluginFailed</c> additions use that same envelope
|
||||
/// without breaking an existing reader.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -29,9 +29,11 @@ namespace AcDream.Runtime.Session;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Never write credential material into this stream.</strong> Every
|
||||
/// event method below takes only identifiers, names, and counts — there is no
|
||||
/// parameter shape that could carry a password, by construction.
|
||||
/// <strong>Never write credential material into this stream.</strong> LA5's
|
||||
/// <see cref="PluginFailed"/> diagnostic is caller-supplied text, so hosts may
|
||||
/// pass only the plugin lifecycle failure and must never append session
|
||||
/// credentials or other secrets. Neither plugin host exposes credentials
|
||||
/// through <c>IPluginHost</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -205,6 +207,27 @@ public sealed class SessionStatusWriter
|
|||
characterName,
|
||||
});
|
||||
|
||||
public void PluginLoaded(string sessionId, string plugin) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "pluginLoaded",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
plugin,
|
||||
});
|
||||
|
||||
public void PluginFailed(string sessionId, string plugin, string error) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "pluginFailed",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
plugin,
|
||||
error,
|
||||
});
|
||||
|
||||
public void Disconnected(string sessionId, string reason)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@
|
|||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Build ordering only. Tests copy this DLL into a temporary plugin root
|
||||
and the production loader loads it through a collectible ALC. -->
|
||||
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.HostPlugin\AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs"
|
||||
Link="Fixtures\LauncherCoreSessionConfigFixture.cs" />
|
||||
|
|
|
|||
|
|
@ -56,6 +56,18 @@ public sealed class SessionConfigurationSharedFixtureTests
|
|||
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]
|
||||
public void AppReaderAcceptsTheProductionShapedSharedFixture()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.App.Plugins;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
|
|
@ -18,4 +19,26 @@ public class BufferedUiRegistryTests
|
|||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
347
tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs
Normal file
347
tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
using System.Text.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Plugins;
|
||||
using AcDream.Core.Plugins;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Platform;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Tests.Fixtures.CampaignLa;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
public sealed class GraphicalPluginSessionTests
|
||||
{
|
||||
private const string FixtureId = "acdream.test.host-fixture";
|
||||
private const string ThrowingId = "acdream.test.throwing-fixture";
|
||||
private const string InitializeThrowingId =
|
||||
"acdream.test.initialize-throwing-fixture";
|
||||
|
||||
[Fact]
|
||||
public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
ApplicationPathSet paths = Paths(temporary.Path);
|
||||
InstallFixture(paths.PluginsDirectory, FixtureId);
|
||||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||
var logger = new CapturingLogger();
|
||||
var state = new WorldGameState();
|
||||
var events = new WorldEvents();
|
||||
var selection = new SelectionState();
|
||||
var ui = new BufferedUiRegistry();
|
||||
var host = new AppPluginHost(logger, state, events, selection, ui);
|
||||
|
||||
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
|
||||
paths,
|
||||
[FixtureId.ToUpperInvariant(), "acdream.test.missing"],
|
||||
"gui-session",
|
||||
host,
|
||||
new SessionStatusWriter(statusPath));
|
||||
plugins.Start();
|
||||
|
||||
Assert.Equal(1, plugins.LoadedCount);
|
||||
Assert.True(host.HasUi);
|
||||
AssertPanelWasRegisteredAndReleaseBinding(ui);
|
||||
Assert.Contains(
|
||||
logger.Messages,
|
||||
message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal));
|
||||
|
||||
JsonElement[] statuses = ReadStatuses(statusPath);
|
||||
Assert.Equal(["started", "pluginLoaded", "pluginFailed"], EventNames(statuses));
|
||||
Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
|
||||
Assert.Equal(
|
||||
"acdream.test.missing",
|
||||
statuses[2].GetProperty("plugin").GetString());
|
||||
Assert.Contains(
|
||||
"not found",
|
||||
statuses[2].GetProperty("error").GetString(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
WeakReference context = Assert.Single(
|
||||
plugins.CaptureLoadContextWeakReferences());
|
||||
plugins.Dispose();
|
||||
Assert.Equal(0, ui.RegistrationCount);
|
||||
Collect(context);
|
||||
Assert.False(context.IsAlive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitEmptyConfiguredSetLoadsNone()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
ApplicationPathSet paths = Paths(temporary.Path);
|
||||
InstallFixture(paths.PluginsDirectory, FixtureId);
|
||||
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 host = new AppPluginHost(
|
||||
new CapturingLogger(),
|
||||
new WorldGameState(),
|
||||
new WorldEvents(),
|
||||
new SelectionState(),
|
||||
ui);
|
||||
|
||||
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
|
||||
paths,
|
||||
descriptor.Plugins,
|
||||
"gui-session",
|
||||
host,
|
||||
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 selection = new SelectionState();
|
||||
var ui = new BufferedUiRegistry();
|
||||
var host = new AppPluginHost(
|
||||
new CapturingLogger(),
|
||||
new WorldGameState(),
|
||||
events,
|
||||
selection,
|
||||
ui);
|
||||
|
||||
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
|
||||
paths,
|
||||
[ThrowingId],
|
||||
"gui-session",
|
||||
host,
|
||||
new SessionStatusWriter(statusPath));
|
||||
plugins.Start();
|
||||
|
||||
Assert.Equal(0, plugins.LoadedCount);
|
||||
Assert.Empty(ui.Drain());
|
||||
Assert.Equal(0, ui.RegistrationCount);
|
||||
events.FireEntitySpawned(new WorldEntitySnapshot(
|
||||
1u,
|
||||
2u,
|
||||
default,
|
||||
System.Numerics.Quaternion.Identity));
|
||||
Assert.True(((ISelectionService)selection).Select(7u));
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializeFailureRollsBackEveryRegistrationBeforeUnload()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
ApplicationPathSet paths = Paths(temporary.Path);
|
||||
string pluginDirectory = InstallFixture(
|
||||
paths.PluginsDirectory,
|
||||
InitializeThrowingId,
|
||||
"initialize-throwing-fixture");
|
||||
File.WriteAllText(
|
||||
Path.Combine(pluginDirectory, "throw-during-initialize"),
|
||||
string.Empty);
|
||||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||
var events = new WorldEvents();
|
||||
var selection = new SelectionState();
|
||||
var ui = new BufferedUiRegistry();
|
||||
var host = new AppPluginHost(
|
||||
new CapturingLogger(),
|
||||
new WorldGameState(),
|
||||
events,
|
||||
selection,
|
||||
ui);
|
||||
|
||||
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
|
||||
paths,
|
||||
[InitializeThrowingId],
|
||||
"gui-session",
|
||||
host,
|
||||
new SessionStatusWriter(statusPath));
|
||||
plugins.Start();
|
||||
|
||||
Assert.Equal(0, plugins.LoadedCount);
|
||||
Assert.Empty(ui.Drain());
|
||||
Assert.Equal(0, ui.RegistrationCount);
|
||||
Assert.Equal(
|
||||
"ui=True;events=True;selection=True",
|
||||
File.ReadAllText(Path.Combine(
|
||||
pluginDirectory,
|
||||
"unload-observation")));
|
||||
events.FireEntitySpawned(new WorldEntitySnapshot(
|
||||
1u,
|
||||
2u,
|
||||
default,
|
||||
System.Numerics.Quaternion.Identity));
|
||||
Assert.True(((ISelectionService)selection).Select(9u));
|
||||
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(
|
||||
Path.Combine(root, "config"),
|
||||
Path.Combine(root, "data"),
|
||||
Path.Combine(root, "cache"),
|
||||
LegacyConfigDirectory: null);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void AssertPanelWasRegisteredAndReleaseBinding(
|
||||
BufferedUiRegistry ui)
|
||||
{
|
||||
BufferedUiRegistry.Pending panel = Assert.Single(ui.Drain());
|
||||
Assert.EndsWith(
|
||||
"fixture-panel.xml",
|
||||
panel.MarkupPath,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
|
||||
panel.Binding.GetType().Assembly.GetName().Name);
|
||||
}
|
||||
|
||||
private static JsonElement[] ReadStatuses(string path) =>
|
||||
File.ReadAllLines(path)
|
||||
.Select(static line => JsonDocument.Parse(line).RootElement.Clone())
|
||||
.ToArray();
|
||||
|
||||
private static string[] EventNames(IEnumerable<JsonElement> events) =>
|
||||
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
|
||||
|
||||
private static string InstallFixture(
|
||||
string root,
|
||||
string id,
|
||||
string directoryName = "host-fixture")
|
||||
{
|
||||
string source = FixtureAssemblyPath();
|
||||
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
||||
string pluginDirectory = Path.Combine(root, directoryName);
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
string fileName = Path.GetFileName(source);
|
||||
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
||||
File.WriteAllText(
|
||||
Path.Combine(pluginDirectory, "plugin.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
id,
|
||||
displayName = "Host fixture",
|
||||
version = "1.0.0",
|
||||
entryDll = fileName,
|
||||
apiVersion = 1,
|
||||
}));
|
||||
return pluginDirectory;
|
||||
}
|
||||
|
||||
private static string FixtureAssemblyPath()
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent!.Name;
|
||||
string root = FindRepoRoot(AppContext.BaseDirectory);
|
||||
return Path.Combine(
|
||||
root,
|
||||
"tests",
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
|
||||
}
|
||||
|
||||
private static string FindRepoRoot(string start)
|
||||
{
|
||||
DirectoryInfo? directory = new(start);
|
||||
while (directory is not null
|
||||
&& !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Repository root not found.");
|
||||
}
|
||||
|
||||
private static void Collect(WeakReference reference)
|
||||
{
|
||||
for (int attempt = 0; attempt < 10 && reference.IsAlive; attempt++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger : IPluginLogger
|
||||
{
|
||||
internal List<string> Messages { get; } = [];
|
||||
|
||||
public void Info(string message) => Messages.Add(message);
|
||||
public void Warn(string message) => Messages.Add(message);
|
||||
public void Error(string message, Exception? exception = null) =>
|
||||
Messages.Add(exception is null ? message : $"{message}: {exception.Message}");
|
||||
}
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
internal TemporaryDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-graphical-plugins-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
for (int attempt = 0; Directory.Exists(Path); attempt++)
|
||||
{
|
||||
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()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
string program = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Program.cs"));
|
||||
string lifetime = GameWindowLifetimeSource();
|
||||
string manifest = Slice(
|
||||
lifetime,
|
||||
|
|
@ -464,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
[
|
||||
"new ResourceShutdownStage(\"host and session barriers\"",
|
||||
"new ResourceShutdownStage(\"physical ingress cleanup\"",
|
||||
"new ResourceShutdownStage(\"plugin host\"",
|
||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"new ResourceShutdownStage(\"live entities\"",
|
||||
|
|
@ -499,6 +505,8 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
|
||||
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
|
||||
"Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))",
|
||||
"new ResourceShutdownStage(\"plugin host\"",
|
||||
"Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
|
||||
Assert.Contains(
|
||||
|
|
@ -509,7 +517,27 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"UiHost? RetainedUiHost,",
|
||||
lifetime,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"IDisposable? Plugins,",
|
||||
lifetime,
|
||||
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(
|
||||
nativeRelease,
|
||||
"TryComplete();",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ public class PluginLoaderTests
|
|||
|
||||
private sealed class StubHost : IPluginHost
|
||||
{
|
||||
public bool HasUi => true;
|
||||
public IPluginLogger Log { get; } = new StubLogger();
|
||||
public IGameState State { get; } = new StubState();
|
||||
public IEvents Events { get; } = new StubEvents();
|
||||
|
|
@ -84,6 +85,8 @@ public class PluginLoaderTests
|
|||
Assert.True(loaded.Success);
|
||||
Assert.NotNull(loaded.Plugin);
|
||||
Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
|
||||
loaded.Plugin.Disable();
|
||||
loaded.LoadContext!.Unload();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -122,5 +125,7 @@ public class PluginLoaderTests
|
|||
|
||||
Assert.False(loaded.Success);
|
||||
Assert.Contains("IAcDreamPlugin", loaded.Error!.Message);
|
||||
Assert.NotNull(loaded.LoadContext);
|
||||
loaded.LoadContext!.Unload();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
201
tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs
Normal file
201
tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Core.Plugins;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Tests.Plugins;
|
||||
|
||||
public sealed class PluginSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public void AbsentAllowListLoadsEveryDiscoveredPlugin()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallFixture(temporary.Path, "alpha", "acdream.test.alpha");
|
||||
InstallFixture(temporary.Path, "beta", "acdream.test.beta");
|
||||
var statuses = new List<PluginSessionStatus>();
|
||||
var plugins = new PluginSession(new StubHost(), statuses.Add);
|
||||
|
||||
plugins.Start([temporary.Path], allowList: null);
|
||||
|
||||
Assert.Equal(2, plugins.LoadedCount);
|
||||
Assert.Equal(
|
||||
["acdream.test.alpha", "acdream.test.beta"],
|
||||
plugins.LoadedPluginIds);
|
||||
Assert.All(
|
||||
statuses,
|
||||
status => Assert.Equal(PluginSessionStatusKind.Loaded, status.Kind));
|
||||
ReleaseAndCollect(plugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowListIsCaseInsensitiveAndOneFailureDoesNotBlockAnotherPlugin()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallFixture(temporary.Path, "good", "acdream.test.good");
|
||||
InstallBroken(temporary.Path, "broken", "acdream.test.broken");
|
||||
var statuses = new List<PluginSessionStatus>();
|
||||
var plugins = new PluginSession(new StubHost(), statuses.Add);
|
||||
|
||||
plugins.Start(
|
||||
[temporary.Path],
|
||||
[
|
||||
"ACDREAM.TEST.BROKEN",
|
||||
"ACDREAM.TEST.GOOD",
|
||||
"acdream.test.missing",
|
||||
]);
|
||||
|
||||
Assert.Equal(["acdream.test.good"], plugins.LoadedPluginIds);
|
||||
Assert.Equal(
|
||||
[
|
||||
("ACDREAM.TEST.BROKEN", PluginSessionStatusKind.Failed),
|
||||
("acdream.test.good", PluginSessionStatusKind.Loaded),
|
||||
("acdream.test.missing", PluginSessionStatusKind.Failed),
|
||||
],
|
||||
statuses.Select(static status => (status.Plugin, status.Kind)));
|
||||
Assert.All(
|
||||
statuses.Where(static status => status.Kind == PluginSessionStatusKind.Failed),
|
||||
status => Assert.False(string.IsNullOrWhiteSpace(status.Error)));
|
||||
ReleaseAndCollect(plugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitEmptyAllowListLoadsNothing()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallFixture(temporary.Path, "fixture", "acdream.test.fixture");
|
||||
var statuses = new List<PluginSessionStatus>();
|
||||
using var plugins = new PluginSession(new StubHost(), statuses.Add);
|
||||
|
||||
plugins.Start([temporary.Path], []);
|
||||
|
||||
Assert.Equal(0, plugins.LoadedCount);
|
||||
Assert.Empty(statuses);
|
||||
}
|
||||
|
||||
private static void ReleaseAndCollect(PluginSession plugins)
|
||||
{
|
||||
IReadOnlyList<WeakReference> contexts =
|
||||
plugins.CaptureLoadContextWeakReferences();
|
||||
plugins.Dispose();
|
||||
for (int attempt = 0;
|
||||
attempt < 10 && contexts.Any(static context => context.IsAlive);
|
||||
attempt++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
}
|
||||
Assert.All(contexts, static context => Assert.False(context.IsAlive));
|
||||
}
|
||||
|
||||
private static void InstallFixture(string root, string folder, string id)
|
||||
{
|
||||
string source = FixturePluginPath();
|
||||
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
||||
string pluginDirectory = Path.Combine(root, folder);
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
string fileName = Path.GetFileName(source);
|
||||
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
||||
WriteManifest(pluginDirectory, id, fileName);
|
||||
}
|
||||
|
||||
private static void InstallBroken(string root, string folder, string id)
|
||||
{
|
||||
string pluginDirectory = Path.Combine(root, folder);
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
WriteManifest(pluginDirectory, id, "missing.dll");
|
||||
}
|
||||
|
||||
private static void WriteManifest(
|
||||
string directory,
|
||||
string id,
|
||||
string entryDll) =>
|
||||
File.WriteAllText(
|
||||
Path.Combine(directory, "plugin.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
id,
|
||||
displayName = id,
|
||||
version = "1.0.0",
|
||||
entryDll,
|
||||
apiVersion = 1,
|
||||
}));
|
||||
|
||||
private static string FixturePluginPath()
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent!.Name;
|
||||
string root = FindRepoRoot(AppContext.BaseDirectory);
|
||||
return Path.Combine(
|
||||
root,
|
||||
"tests",
|
||||
"AcDream.Core.Tests.Fixtures.HelloPlugin",
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
"AcDream.Core.Tests.Fixtures.HelloPlugin.dll");
|
||||
}
|
||||
|
||||
private static string FindRepoRoot(string start)
|
||||
{
|
||||
DirectoryInfo? directory = new(start);
|
||||
while (directory is not null
|
||||
&& !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Repository root not found.");
|
||||
}
|
||||
|
||||
private sealed class StubHost : IPluginHost
|
||||
{
|
||||
public bool HasUi => false;
|
||||
public IPluginLogger Log { get; } = new StubLogger();
|
||||
public IGameState State { get; } = new StubState();
|
||||
public IEvents Events { get; } = new StubEvents();
|
||||
public ISelectionService Selection { get; } = new SelectionState();
|
||||
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||
}
|
||||
|
||||
private sealed class StubLogger : IPluginLogger
|
||||
{
|
||||
public void Info(string message) { }
|
||||
public void Warn(string message) { }
|
||||
public void Error(string message, Exception? exception = null) { }
|
||||
}
|
||||
|
||||
private sealed class StubState : IGameState
|
||||
{
|
||||
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
|
||||
}
|
||||
|
||||
private sealed class StubEvents : IEvents
|
||||
{
|
||||
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
internal TemporaryDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-plugin-session-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,15 @@
|
|||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Build ordering only. Tests copy this DLL into a temporary plugin root
|
||||
and the production loader loads it through a collectible ALC. -->
|
||||
<ProjectReference Include="..\AcDream.Plugin.Tests.Fixtures.HostPlugin\AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Fixtures\campaign-la\LauncherCoreSessionConfigFixture.cs"
|
||||
Link="Fixtures\LauncherCoreSessionConfigFixture.cs" />
|
||||
|
|
|
|||
429
tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs
Normal file
429
tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
using System.Text.Json;
|
||||
using System.Net;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Headless.Configuration;
|
||||
using AcDream.Headless.Credentials;
|
||||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Hosting;
|
||||
using AcDream.Headless.Plugins;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Tests.Fixtures.CampaignLa;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
public sealed class HeadlessPluginSessionTests
|
||||
{
|
||||
private const string FixtureId = "acdream.test.host-fixture";
|
||||
private const string BrokenId = "acdream.test.broken";
|
||||
private const string ThrowingId = "acdream.test.throwing-fixture";
|
||||
|
||||
[Fact]
|
||||
public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallFixture(temporary.Path, FixtureId);
|
||||
InstallBrokenPlugin(temporary.Path, BrokenId);
|
||||
var output = new StringWriter();
|
||||
var diagnostics = new HeadlessDiagnosticWriter(output);
|
||||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||
using var session = new HeadlessSessionHost(
|
||||
Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath),
|
||||
credential,
|
||||
diagnostics,
|
||||
new FixtureSessionOperations(),
|
||||
pluginRoots: [temporary.Path]);
|
||||
HeadlessPluginSession plugins = session.Plugins;
|
||||
_ = session.Start();
|
||||
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
|
||||
|
||||
Assert.Equal(1, plugins.LoadedCount);
|
||||
Assert.False(plugins.Host.HasUi);
|
||||
Assert.Same(NoOpUiRegistry.Instance, plugins.Host.Ui);
|
||||
Assert.Same(
|
||||
session.Runtime.ActionOwner.Selection,
|
||||
plugins.Host.Selection);
|
||||
WorldEntitySnapshot first = Assert.Single(plugins.Host.State.Entities);
|
||||
Assert.Equal(1_000_000u, first.Id);
|
||||
Assert.Equal(0x02000001u, first.SourceId);
|
||||
|
||||
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f));
|
||||
Assert.Equal(2, plugins.Host.State.Entities.Count);
|
||||
|
||||
JsonElement[] statuses = ReadStatuses(statusPath);
|
||||
Assert.Equal(
|
||||
[
|
||||
"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(
|
||||
"entry dll not found",
|
||||
statuses[2].GetProperty("error").GetString()!,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString());
|
||||
|
||||
WeakReference context = Assert.Single(
|
||||
plugins.CaptureLoadContextWeakReferences());
|
||||
session.Dispose();
|
||||
Assert.Contains("fixture-disabled:entitiesSeen=2", output.ToString());
|
||||
Assert.True(session.Runtime.CaptureOwnership().IsConverged);
|
||||
Assert.True(credential.IsDisposed);
|
||||
Collect(context);
|
||||
Assert.False(context.IsAlive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitEmptyConfiguredSetLoadsNone()
|
||||
{
|
||||
using var temporary = new TemporaryDirectory();
|
||||
InstallFixture(temporary.Path, FixtureId);
|
||||
var output = new StringWriter();
|
||||
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
|
||||
var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||
|
||||
using var session = new HeadlessSessionHost(
|
||||
Descriptor([], statusPath),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(output),
|
||||
new FixtureSessionOperations(),
|
||||
pluginRoots: [temporary.Path]);
|
||||
|
||||
_ = session.Start();
|
||||
Assert.Equal(0, session.Plugins.LoadedCount);
|
||||
Assert.Equal(
|
||||
["started", "connected", "characterList", "enteredWorld"],
|
||||
EventNames(ReadStatuses(statusPath)));
|
||||
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.True(((ISelectionService)session.Runtime.ActionOwner.Selection)
|
||||
.Select(7u));
|
||||
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)));
|
||||
using var registrationStarted = new ManualResetEventSlim();
|
||||
Task registration = Task.Run(() =>
|
||||
{
|
||||
registrationStarted.Set();
|
||||
_ = session.Runtime.EntityObjects.RegisterEntity(
|
||||
Spawn(0x50000002u, 2f));
|
||||
});
|
||||
Assert.True(registrationStarted.Wait(TimeSpan.FromSeconds(10)));
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100));
|
||||
Assert.False(registration.IsCompleted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseReplay.Set();
|
||||
}
|
||||
await Task.WhenAll(subscribe, registration)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
host.Events.EntitySpawned -= handler;
|
||||
|
||||
Assert.Equal([1_000_000u, 1_000_001u], observed);
|
||||
}
|
||||
|
||||
private static HeadlessSessionDescriptor Descriptor(
|
||||
List<string> plugins,
|
||||
string statusPath) => new()
|
||||
{
|
||||
Id = "headless-session",
|
||||
Endpoint = new HeadlessEndpointDescriptor
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 9000,
|
||||
},
|
||||
Account = "account",
|
||||
Character = new HeadlessCharacterSelector
|
||||
{
|
||||
Name = "Fixture",
|
||||
},
|
||||
Policy = new HeadlessBotPolicyDescriptor
|
||||
{
|
||||
Id = "idle",
|
||||
},
|
||||
Credential = new HeadlessCredentialReference
|
||||
{
|
||||
Provider = HeadlessCredentialProviderKind.Environment,
|
||||
Reference = "FIXTURE_PASSWORD",
|
||||
},
|
||||
Plugins = plugins,
|
||||
StatusFile = statusPath,
|
||||
};
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, float x) => new(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(
|
||||
0x01010001u,
|
||||
x,
|
||||
10f,
|
||||
5f,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
0x02000001u,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
"Fixture",
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
private static JsonElement[] ReadStatuses(string path) =>
|
||||
File.ReadAllLines(path)
|
||||
.Select(static line => JsonDocument.Parse(line).RootElement.Clone())
|
||||
.ToArray();
|
||||
|
||||
private static string[] EventNames(IEnumerable<JsonElement> events) =>
|
||||
events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
|
||||
|
||||
private static string InstallFixture(
|
||||
string root,
|
||||
string id,
|
||||
string directoryName = "host-fixture")
|
||||
{
|
||||
string source = FixtureAssemblyPath();
|
||||
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
|
||||
string pluginDirectory = Path.Combine(root, directoryName);
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
string fileName = Path.GetFileName(source);
|
||||
File.Copy(source, Path.Combine(pluginDirectory, fileName));
|
||||
WriteManifest(pluginDirectory, id, fileName);
|
||||
return pluginDirectory;
|
||||
}
|
||||
|
||||
private static void InstallBrokenPlugin(string root, string id)
|
||||
{
|
||||
string pluginDirectory = Path.Combine(root, "broken");
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
WriteManifest(pluginDirectory, id, "missing.dll");
|
||||
}
|
||||
|
||||
private static void WriteManifest(
|
||||
string directory,
|
||||
string id,
|
||||
string entryDll) =>
|
||||
File.WriteAllText(
|
||||
Path.Combine(directory, "plugin.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
id,
|
||||
displayName = "Host fixture",
|
||||
version = "1.0.0",
|
||||
entryDll,
|
||||
apiVersion = 1,
|
||||
}));
|
||||
|
||||
private static string FixtureAssemblyPath()
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent!.Name;
|
||||
string root = FindRepoRoot(AppContext.BaseDirectory);
|
||||
return Path.Combine(
|
||||
root,
|
||||
"tests",
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin",
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
"AcDream.Plugin.Tests.Fixtures.HostPlugin.dll");
|
||||
}
|
||||
|
||||
private static string FindRepoRoot(string start)
|
||||
{
|
||||
DirectoryInfo? directory = new(start);
|
||||
while (directory is not null
|
||||
&& !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Repository root not found.");
|
||||
}
|
||||
|
||||
private static void Collect(WeakReference reference)
|
||||
{
|
||||
for (int attempt = 0; attempt < 10 && reference.IsAlive; attempt++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
internal TemporaryDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-headless-plugins-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
for (int attempt = 0; Directory.Exists(Path); attempt++)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ public sealed class SessionConfigComposerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays()
|
||||
public void EmptyPluginsRemainAnExplicitLoadNoneAllowListWhileLoginCommandsAreOmitted()
|
||||
{
|
||||
CharacterProfile character = Character(LaunchMode.Gui);
|
||||
character.Plugins = [];
|
||||
|
|
@ -190,7 +190,8 @@ public sealed class SessionConfigComposerTests
|
|||
sessionId: "session-empty-lists");
|
||||
|
||||
JsonObject session = SingleSession(composed);
|
||||
Assert.False(session.ContainsKey("plugins"));
|
||||
Assert.True(session.ContainsKey("plugins"));
|
||||
Assert.Empty(session["plugins"]!.AsArray());
|
||||
Assert.False(session.ContainsKey("loginCommands"));
|
||||
}
|
||||
|
||||
|
|
@ -243,7 +244,7 @@ public sealed class SessionConfigComposerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands()
|
||||
public void ProbeModeSetsModeAndCarriesExplicitLoadNonePluginAllowList()
|
||||
{
|
||||
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
|
||||
Server(),
|
||||
|
|
@ -256,7 +257,7 @@ public sealed class SessionConfigComposerTests
|
|||
|
||||
AssertKeys(
|
||||
session,
|
||||
"id", "mode", "endpoint", "account", "credential", "statusFile");
|
||||
"id", "mode", "endpoint", "account", "credential", "plugins", "statusFile");
|
||||
|
||||
Assert.Equal("session-probe", (string?)session["id"]);
|
||||
Assert.Equal("probe", (string?)session["mode"]);
|
||||
|
|
@ -266,7 +267,7 @@ public sealed class SessionConfigComposerTests
|
|||
Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
|
||||
Assert.False(session.ContainsKey("character"));
|
||||
Assert.False(session.ContainsKey("policy"));
|
||||
Assert.False(session.ContainsKey("plugins"));
|
||||
Assert.Empty(session["plugins"]!.AsArray());
|
||||
Assert.False(session.ContainsKey("loginCommands"));
|
||||
Assert.False(session.ContainsKey("loginCommandDelayMs"));
|
||||
Assert.Equal(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- The host owns this contract assembly. Keeping it out of the fixture's
|
||||
output is required for IAcDreamPlugin type identity in the collectible
|
||||
load context. -->
|
||||
<ProjectReference Include="..\..\src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
|
||||
<Private>false</Private>
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
130
tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs
Normal file
130
tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-host LA5 fixture. It deliberately takes the same path on graphical
|
||||
/// and no-window hosts: observe the capability, register UI, and subscribe to
|
||||
/// gameplay events. A headless registry must make the UI call harmless without
|
||||
/// retaining this instance in the default load context.
|
||||
/// </summary>
|
||||
public sealed class HostPlugin : IAcDreamPlugin
|
||||
{
|
||||
private IPluginHost? _host;
|
||||
private string? _assemblyDirectory;
|
||||
private bool _throwAfterRegistration;
|
||||
private bool _throwDuringInitialize;
|
||||
private int _entitiesSeen;
|
||||
|
||||
public void Initialize(IPluginHost host)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_assemblyDirectory = Path.GetDirectoryName(
|
||||
typeof(HostPlugin).Assembly.Location);
|
||||
_throwAfterRegistration = File.Exists(
|
||||
Path.Combine(_assemblyDirectory!, "throw-after-register"));
|
||||
_throwDuringInitialize = File.Exists(
|
||||
Path.Combine(_assemblyDirectory!, "throw-during-initialize"));
|
||||
host.Log.Info($"fixture-initialized:hasUi={host.HasUi}");
|
||||
if (_throwDuringInitialize)
|
||||
{
|
||||
RegisterHostCallbacks(host);
|
||||
AssemblyLoadContext.GetLoadContext(typeof(HostPlugin).Assembly)!
|
||||
.Unloading += OnUnloading;
|
||||
throw new InvalidOperationException(
|
||||
"fixture initialize failed after registering UI, entity, and selection callbacks");
|
||||
}
|
||||
}
|
||||
|
||||
public void Enable()
|
||||
{
|
||||
IPluginHost host = _host
|
||||
?? throw new InvalidOperationException("The fixture was not initialized.");
|
||||
RegisterHostCallbacks(host);
|
||||
if (_throwAfterRegistration)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"fixture enable failed after registering UI and events");
|
||||
}
|
||||
host.Log.Info(
|
||||
$"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}");
|
||||
}
|
||||
|
||||
public void Disable()
|
||||
{
|
||||
IPluginHost? host = _host;
|
||||
if (host is null)
|
||||
return;
|
||||
if (_throwAfterRegistration || _throwDuringInitialize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"fixture disable intentionally refuses cleanup");
|
||||
}
|
||||
host.Events.EntitySpawned -= OnEntitySpawned;
|
||||
host.Selection.Changed -= OnSelectionChanged;
|
||||
host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}");
|
||||
_host = null;
|
||||
}
|
||||
|
||||
private void RegisterHostCallbacks(IPluginHost host)
|
||||
{
|
||||
host.Ui.AddMarkupPanel(
|
||||
Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
|
||||
this);
|
||||
host.Events.EntitySpawned += OnEntitySpawned;
|
||||
host.Selection.Changed += OnSelectionChanged;
|
||||
}
|
||||
|
||||
private void OnEntitySpawned(WorldEntitySnapshot snapshot)
|
||||
{
|
||||
_entitiesSeen++;
|
||||
RecordUnexpectedCallback(snapshot.Id);
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(SelectionChangedEvent change) =>
|
||||
RecordUnexpectedCallback(change.SelectedObjectId ?? 0u);
|
||||
|
||||
private void RecordUnexpectedCallback(uint objectId)
|
||||
{
|
||||
if ((_throwAfterRegistration || _throwDuringInitialize)
|
||||
&& _assemblyDirectory is not null)
|
||||
{
|
||||
File.AppendAllText(
|
||||
Path.Combine(_assemblyDirectory, "unexpected-callback"),
|
||||
$"{objectId}{Environment.NewLine}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnUnloading(AssemblyLoadContext context)
|
||||
{
|
||||
IPluginHost host = _host!;
|
||||
bool uiClosed = Rejects(() => host.Ui.AddMarkupPanel(
|
||||
Path.Combine(AppContext.BaseDirectory, "unloading-panel.xml"),
|
||||
this));
|
||||
bool eventsClosed = Rejects(() =>
|
||||
{
|
||||
host.Events.EntitySpawned += OnEntitySpawned;
|
||||
});
|
||||
bool selectionClosed = Rejects(() =>
|
||||
{
|
||||
host.Selection.Changed += OnSelectionChanged;
|
||||
});
|
||||
File.WriteAllText(
|
||||
Path.Combine(_assemblyDirectory!, "unload-observation"),
|
||||
$"ui={uiClosed};events={eventsClosed};selection={selectionClosed}");
|
||||
}
|
||||
|
||||
private static bool Rejects(Action action)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
return false;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,11 +30,13 @@ public sealed class SessionStatusWriterTests
|
|||
new LiveSessionRosterEntry(0x50000002u, "Grey", 10u),
|
||||
]));
|
||||
writer.EnteredWorld("s1", 0x50000001u, "Ready");
|
||||
writer.PluginLoaded("s1", "acdream.good");
|
||||
writer.PluginFailed("s1", "acdream.bad", "enable failed");
|
||||
writer.Disconnected("s1", "stopped");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
|
||||
string[] lines = File.ReadAllLines(file.Path);
|
||||
Assert.Equal(6, lines.Length);
|
||||
Assert.Equal(8, lines.Length);
|
||||
|
||||
JsonElement started = Parse(lines[0]);
|
||||
Assert.Equal(1, started.GetProperty("v").GetInt32());
|
||||
|
|
@ -62,11 +64,20 @@ public sealed class SessionStatusWriterTests
|
|||
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
|
||||
Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString());
|
||||
|
||||
JsonElement disconnected = Parse(lines[4]);
|
||||
JsonElement pluginLoaded = Parse(lines[4]);
|
||||
Assert.Equal("pluginLoaded", pluginLoaded.GetProperty("e").GetString());
|
||||
Assert.Equal("acdream.good", pluginLoaded.GetProperty("plugin").GetString());
|
||||
|
||||
JsonElement pluginFailed = Parse(lines[5]);
|
||||
Assert.Equal("pluginFailed", pluginFailed.GetProperty("e").GetString());
|
||||
Assert.Equal("acdream.bad", pluginFailed.GetProperty("plugin").GetString());
|
||||
Assert.Equal("enable failed", pluginFailed.GetProperty("error").GetString());
|
||||
|
||||
JsonElement disconnected = Parse(lines[6]);
|
||||
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
|
||||
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
|
||||
|
||||
JsonElement exited = Parse(lines[5]);
|
||||
JsonElement exited = Parse(lines[7]);
|
||||
Assert.Equal("exited", exited.GetProperty("e").GetString());
|
||||
Assert.Equal(0, exited.GetProperty("code").GetInt32());
|
||||
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
|
||||
|
|
@ -80,6 +91,8 @@ public sealed class SessionStatusWriterTests
|
|||
|
||||
writer.Started("s1");
|
||||
writer.Connected("s1");
|
||||
writer.PluginLoaded("s1", "acdream.good");
|
||||
writer.PluginFailed("s1", "acdream.bad", "failed");
|
||||
writer.Disconnected("s1", "stopped");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
|
||||
|
|
@ -164,9 +177,10 @@ public sealed class SessionStatusWriterTests
|
|||
/// credential material into this stream" contract: each event kind
|
||||
/// serializes EXACTLY its pinned property set — the shared envelope
|
||||
/// (<c>v</c>/<c>e</c>/<c>t</c>/<c>sessionId</c>) plus that event's own
|
||||
/// named fields, nothing else. An extra property (a smuggled password,
|
||||
/// or any other accidental field) fails this test by construction,
|
||||
/// regardless of what value it carries.
|
||||
/// named fields, nothing else. An extra credential-shaped or otherwise
|
||||
/// accidental property fails this test by construction. LA5's documented
|
||||
/// <c>pluginFailed.error</c> diagnostic is the one free-text value and its
|
||||
/// caller remains responsible for never appending session secrets.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse()
|
||||
|
|
@ -183,11 +197,13 @@ public sealed class SessionStatusWriterTests
|
|||
11,
|
||||
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
|
||||
writer.EnteredWorld("bot", 0x50000001u, "Ready");
|
||||
writer.PluginLoaded("bot", "acdream.good");
|
||||
writer.PluginFailed("bot", "acdream.bad", "enable failed");
|
||||
writer.Disconnected("bot", "stopped");
|
||||
writer.Exited("bot", 0, "disposed");
|
||||
|
||||
string[] lines = File.ReadAllLines(file.Path);
|
||||
Assert.Equal(6, lines.Length);
|
||||
Assert.Equal(8, lines.Length);
|
||||
|
||||
AssertExactProperties(lines[0], "v", "e", "t", "sessionId");
|
||||
AssertExactProperties(lines[1], "v", "e", "t", "sessionId");
|
||||
|
|
@ -196,8 +212,11 @@ public sealed class SessionStatusWriterTests
|
|||
"v", "e", "t", "sessionId", "accountName", "slotCount", "characters");
|
||||
AssertExactProperties(
|
||||
lines[3], "v", "e", "t", "sessionId", "characterId", "characterName");
|
||||
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason");
|
||||
AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason");
|
||||
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin");
|
||||
AssertExactProperties(
|
||||
lines[5], "v", "e", "t", "sessionId", "plugin", "error");
|
||||
AssertExactProperties(lines[6], "v", "e", "t", "sessionId", "reason");
|
||||
AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "code", "reason");
|
||||
|
||||
// The nested characters[] entries are exact too — the exact shape a
|
||||
// password could otherwise be smuggled through.
|
||||
|
|
|
|||
|
|
@ -55,4 +55,66 @@ internal static class LauncherCoreSessionConfigFixture
|
|||
|
||||
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