fix(plugins): close LA5 host lifecycle review

This commit is contained in:
Erik 2026-08-14 19:05:13 +02:00
parent 95f4be94db
commit fbe9c8a288
25 changed files with 1043 additions and 120 deletions

View file

@ -4,11 +4,7 @@ using AcDream.Runtime.Session;
namespace AcDream.App.Composition; namespace AcDream.App.Composition;
internal sealed record SessionStartDependencies( internal sealed record SessionStartDependencies(
Action<string> Log, Action<string> Log);
/// <summary>Campaign LA slice LA1: no-op when no statusFile was
/// configured.</summary>
SessionStatusWriter StatusWriter,
string SessionId);
/// <summary> /// <summary>
/// Terminal startup phase. Every callback, command target, and frame root is /// Terminal startup phase. Every callback, command target, and frame root is
@ -26,9 +22,6 @@ internal sealed class SessionStartCompositionPhase
public void Start(FrameRootResult frame) public void Start(FrameRootResult frame)
{ {
ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(frame);
// Campaign LA slice LA1: "started" = session host start — the
// earliest point the graphical host actually attempts to connect.
_dependencies.StatusWriter.Started(_dependencies.SessionId);
RuntimeSessionStartResult result = RuntimeSessionStartResult result =
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation); frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
Report(result, _dependencies.Log); Report(result, _dependencies.Log);

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.Plugin.Abstractions; using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins; namespace AcDream.App.Plugins;
@ -8,20 +9,119 @@ namespace AcDream.App.Plugins;
/// Program.cs before the GL window opens) until GameWindow drains them into the /// Program.cs before the GL window opens) until GameWindow drains them into the
/// UiHost tree after construction. /// UiHost tree after construction.
/// </summary> /// </summary>
public sealed class BufferedUiRegistry : IUiRegistry public sealed class BufferedUiRegistry : IScopedUiRegistry
{ {
public readonly record struct Pending(string MarkupPath, object Binding); public readonly record struct Pending(string MarkupPath, object Binding)
{
internal long RegistrationId { get; init; }
}
private readonly List<Pending> _pending = new(); private sealed class Registration(string markupPath, object binding)
{
internal string MarkupPath { get; } = markupPath;
internal object Binding { get; } = binding;
internal bool Drained { get; set; }
internal UiRoot? Root { get; set; }
internal UiElement? Element { get; set; }
}
private readonly object _gate = new();
private readonly Dictionary<long, Registration> _registrations = [];
private long _nextRegistrationId;
public void AddMarkupPanel(string markupPath, object binding) public void AddMarkupPanel(string markupPath, object binding)
=> _pending.Add(new Pending(markupPath, binding)); => _ = RegisterMarkupPanel(markupPath, binding);
/// <summary>Return + clear all buffered registrations.</summary> public IDisposable RegisterMarkupPanel(string markupPath, object binding)
{
ArgumentException.ThrowIfNullOrWhiteSpace(markupPath);
ArgumentNullException.ThrowIfNull(binding);
long id;
lock (_gate)
{
id = checked(++_nextRegistrationId);
_registrations.Add(id, new Registration(markupPath, binding));
}
return new RegistrationToken(this, id);
}
/// <summary>Returns each not-yet-drained active registration once.</summary>
public IReadOnlyList<Pending> Drain() public IReadOnlyList<Pending> Drain()
{ {
var copy = _pending.ToArray(); lock (_gate)
_pending.Clear(); {
return copy; var pending = new List<Pending>(_registrations.Count);
foreach ((long id, Registration registration) in _registrations)
{
if (registration.Drained)
continue;
registration.Drained = true;
pending.Add(new Pending(
registration.MarkupPath,
registration.Binding)
{
RegistrationId = id,
});
}
return pending;
}
}
internal void CompleteMount(Pending pending, UiRoot root, UiElement element)
{
bool stillRegistered;
lock (_gate)
{
stillRegistered = _registrations.TryGetValue(
pending.RegistrationId,
out Registration? registration);
if (stillRegistered)
{
registration!.Root = root;
registration.Element = element;
}
}
// A plugin can fail/disable while markup is being built. Never leave
// the just-built child mounted if its host-owned token was rolled back.
if (!stillRegistered)
root.RemoveChild(element);
}
internal void FailMount(Pending pending) => Remove(pending.RegistrationId);
internal int RegistrationCount
{
get
{
lock (_gate)
return _registrations.Count;
}
}
private void Remove(long id)
{
UiRoot? root;
UiElement? element;
lock (_gate)
{
if (!_registrations.Remove(id, out Registration? registration))
return;
root = registration.Root;
element = registration.Element;
}
if (root is not null && element is not null)
root.RemoveChild(element);
}
private sealed class RegistrationToken(
BufferedUiRegistry owner,
long registrationId) : IDisposable
{
private BufferedUiRegistry? _owner = owner;
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Remove(registrationId);
} }
} }

View file

@ -14,10 +14,24 @@ namespace AcDream.App.Plugins;
internal sealed class GraphicalPluginSession : IDisposable internal sealed class GraphicalPluginSession : IDisposable
{ {
private readonly PluginSession _plugins; private readonly PluginSession _plugins;
private readonly string[] _roots;
private readonly IReadOnlyList<string>? _allowList;
private readonly string _sessionId;
private readonly SessionStatusWriter _statusWriter;
private bool _started;
private GraphicalPluginSession(PluginSession plugins) private GraphicalPluginSession(
PluginSession plugins,
string[] roots,
IReadOnlyList<string>? allowList,
string sessionId,
SessionStatusWriter statusWriter)
{ {
_plugins = plugins; _plugins = plugins;
_roots = roots;
_allowList = allowList;
_sessionId = sessionId;
_statusWriter = statusWriter;
} }
internal int LoadedCount => _plugins.LoadedCount; internal int LoadedCount => _plugins.LoadedCount;
@ -25,7 +39,7 @@ internal sealed class GraphicalPluginSession : IDisposable
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() => internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
_plugins.CaptureLoadContextWeakReferences(); _plugins.CaptureLoadContextWeakReferences();
internal static GraphicalPluginSession Start( internal static GraphicalPluginSession Create(
ApplicationPathSet paths, ApplicationPathSet paths,
IReadOnlyList<string>? allowList, IReadOnlyList<string>? allowList,
string sessionId, string sessionId,
@ -40,21 +54,28 @@ internal sealed class GraphicalPluginSession : IDisposable
var plugins = new PluginSession( var plugins = new PluginSession(
host, host,
status => Report(statusWriter, sessionId, status)); status => Report(statusWriter, sessionId, status));
try return new GraphicalPluginSession(
{ plugins,
plugins.Start(
[ [
Path.Combine(AppContext.BaseDirectory, "plugins"), Path.Combine(AppContext.BaseDirectory, "plugins"),
paths.PluginsDirectory, paths.PluginsDirectory,
], ],
allowList); allowList,
return new GraphicalPluginSession(plugins); sessionId,
} statusWriter);
catch }
{
plugins.Dispose(); internal void Start()
throw; {
} if (_started)
throw new InvalidOperationException(
"The graphical plugin session has already started.");
_started = true;
// Both real hosts publish the same startup prefix: started first,
// then one outcome for each configured plugin, then connection work.
_statusWriter.Started(_sessionId);
_plugins.Start(_roots, _allowList);
} }
public void Dispose() => _plugins.Dispose(); public void Dispose() => _plugins.Dispose();

View file

@ -161,12 +161,13 @@ var host = new AppPluginHost(
worldEvents, worldEvents,
window.Selection, window.Selection,
uiRegistry); uiRegistry);
using var pluginSession = GraphicalPluginSession.Start( GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
applicationPaths, applicationPaths,
runtimeOptions.Plugins, runtimeOptions.Plugins,
runtimeOptions.SessionId ?? "app", runtimeOptions.SessionId ?? "app",
host, host,
window.StatusWriter); window.StatusWriter);
window.StartPluginHosting(pluginSession);
try try
{ {
@ -182,7 +183,6 @@ try
} }
finally finally
{ {
pluginSession.Dispose();
Log.CloseAndFlush(); Log.CloseAndFlush();
} }

View file

@ -431,6 +431,7 @@ public sealed class GameWindow :
_creatureAppraisalFramePresenter; _creatureAppraisalFramePresenter;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
// Campaign V slice V11 deleted the ImGui developer-tools frontend along // Campaign V slice V11 deleted the ImGui developer-tools frontend along
// with the OpenGL backend it required, so no host ever composes a // with the OpenGL backend it required, so no host ever composes a
// developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches // developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches
@ -723,6 +724,24 @@ public sealed class GameWindow :
_movementTruthDiagnostics); _movementTruthDiagnostics);
} }
/// <summary>
/// Transfers the graphical plugin lifetime into the window shutdown graph
/// and starts it before retained UI construction drains registrations.
/// </summary>
internal void StartPluginHosting(
AcDream.App.Plugins.GraphicalPluginSession pluginSession)
{
ArgumentNullException.ThrowIfNull(pluginSession);
if (_pluginSession is not null)
{
throw new InvalidOperationException(
"The graphical plugin session is already attached.");
}
_pluginSession = pluginSession;
pluginSession.Start();
}
public void Run() public void Run()
{ {
_platformServices.ConfigureWindowBackend(); _platformServices.ConfigureWindowBackend();
@ -1555,9 +1574,7 @@ public sealed class GameWindow :
sessionPlayer), sessionPlayer),
frameRoots => new SessionStartCompositionPhase( frameRoots => new SessionStartCompositionPhase(
new SessionStartDependencies( new SessionStartDependencies(
Console.WriteLine, Console.WriteLine))
_statusWriter,
_options.SessionId ?? "app"))
.Start(frameRoots)); .Start(frameRoots));
} }
@ -1704,6 +1721,7 @@ public sealed class GameWindow :
_kbSource, _kbSource,
_retailUiLease, _retailUiLease,
_uiHost, _uiHost,
_pluginSession,
_runtime, _runtime,
_movementInput, _movementInput,
_cameraInput, _cameraInput,

View file

@ -71,6 +71,7 @@ internal sealed record IngressShutdownRoots(
RetailUiRuntimeLease RetailUi, RetailUiRuntimeLease RetailUi,
// Keeps failed physical UI bindings alive through native-window release. // Keeps failed physical UI bindings alive through native-window release.
UiHost? RetainedUiHost, UiHost? RetainedUiHost,
IDisposable? Plugins,
GameRuntime Runtime, GameRuntime Runtime,
DispatcherMovementInputSource MovementInput, DispatcherMovementInputSource MovementInput,
DispatcherCameraInputSource CameraInput, DispatcherCameraInputSource CameraInput,
@ -422,6 +423,10 @@ internal static class GameWindowShutdownManifest
Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)), Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)),
Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)), Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)),
]), ]),
new ResourceShutdownStage("plugin host",
[
Hard("plugins", () => ingress.Plugins?.Dispose()),
]),
new ResourceShutdownStage("frame borrowers", new ResourceShutdownStage("frame borrowers",
[ [
Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()), Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()),

View file

@ -3273,10 +3273,12 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite, _bindings.Assets.ResolveSprite,
_bindings.Assets.Controls); _bindings.Assets.Controls);
Host.Root.AddChild(element); Host.Root.AddChild(element);
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}"); Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
} }
catch (Exception ex) catch (Exception ex)
{ {
_bindings.Plugins.FailMount(panel);
Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}"); Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}");
} }
} }

View file

@ -7,14 +7,17 @@ namespace AcDream.Core.Plugins;
/// Outcome of a plugin load attempt. /// Outcome of a plugin load attempt.
/// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/> /// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/>
/// owns its assembly, and <see cref="Error"/> is null.</para> /// owns its assembly, and <see cref="Error"/> is null.</para>
/// <para>On failure, <see cref="Plugin"/> and <see cref="LoadContext"/> are null and /// <para>On failure, <see cref="Plugin"/> and <see cref="LoadContext"/> are null,
/// <see cref="Error"/> describes what went wrong.</para> /// <see cref="Error"/> describes what went wrong, and
/// <see cref="ReleasedLoadContext"/> weakly observes any collectible context
/// that was already released during rollback.</para>
/// </summary> /// </summary>
public sealed record LoadedPlugin( public sealed record LoadedPlugin(
PluginManifest Manifest, PluginManifest Manifest,
IAcDreamPlugin? Plugin, IAcDreamPlugin? Plugin,
AssemblyLoadContext? LoadContext, AssemblyLoadContext? LoadContext,
Exception? Error) Exception? Error,
WeakReference? ReleasedLoadContext = null)
{ {
public bool Success => Plugin is not null && Error is null; public bool Success => Plugin is not null && Error is null;
} }

View file

@ -48,13 +48,15 @@ public static class PluginLoader
if (pluginType is null) if (pluginType is null)
{ {
var released = new WeakReference(alc);
alc.Unload(); alc.Unload();
return new LoadedPlugin( return new LoadedPlugin(
manifest, manifest,
Plugin: null, Plugin: null,
LoadContext: null, LoadContext: null,
Error: new InvalidOperationException( Error: new InvalidOperationException(
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}")); $"no IAcDreamPlugin implementation found in {manifest.EntryDll}"),
ReleasedLoadContext: released);
} }
instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
@ -68,9 +70,15 @@ public static class PluginLoader
// as an Enable failure before releasing the collectible context. // as an Enable failure before releasing the collectible context.
try { instance?.Disable(); } try { instance?.Disable(); }
catch { } catch { }
WeakReference? released = alc is null ? null : new WeakReference(alc);
try { alc?.Unload(); } try { alc?.Unload(); }
catch { } catch { }
return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex); return new LoadedPlugin(
manifest,
Plugin: null,
LoadContext: null,
Error: ex,
ReleasedLoadContext: released);
} }
} }
} }

View file

@ -27,7 +27,8 @@ public sealed class PluginSession : IDisposable
{ {
private readonly IPluginHost _host; private readonly IPluginHost _host;
private readonly Action<PluginSessionStatus>? _report; private readonly Action<PluginSessionStatus>? _report;
private readonly List<LoadedPlugin> _loaded = []; private readonly List<ActivePlugin> _loaded = [];
private readonly List<WeakReference> _releasedContexts = [];
private bool _started; private bool _started;
private bool _disposed; private bool _disposed;
@ -42,7 +43,7 @@ public sealed class PluginSession : IDisposable
public int LoadedCount => _loaded.Count; public int LoadedCount => _loaded.Count;
public IReadOnlyList<string> LoadedPluginIds => public IReadOnlyList<string> LoadedPluginIds =>
_loaded.Select(static plugin => plugin.Manifest.Id).ToArray(); _loaded.Select(static active => active.Loaded.Manifest.Id).ToArray();
/// <summary> /// <summary>
/// Discovers and starts the configured set exactly once. A /// Discovers and starts the configured set exactly once. A
@ -143,9 +144,11 @@ public sealed class PluginSession : IDisposable
/// owned by this session. The returned weak references do not delay unload. /// owned by this session. The returned weak references do not delay unload.
/// </summary> /// </summary>
public IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() => public IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
_loaded [
.Select(static plugin => new WeakReference(plugin.LoadContext!)) .. _releasedContexts,
.ToArray(); .. _loaded.Select(static active =>
new WeakReference(active.Loaded.LoadContext!)),
];
public void Dispose() public void Dispose()
{ {
@ -155,7 +158,8 @@ public sealed class PluginSession : IDisposable
for (int index = _loaded.Count - 1; index >= 0; index--) for (int index = _loaded.Count - 1; index >= 0; index--)
{ {
LoadedPlugin loaded = _loaded[index]; ActivePlugin active = _loaded[index];
LoadedPlugin loaded = active.Loaded;
try try
{ {
loaded.Plugin!.Disable(); loaded.Plugin!.Disable();
@ -169,6 +173,11 @@ public sealed class PluginSession : IDisposable
error); error);
} }
// Host-owned registrations are released even when Disable throws.
// This must precede ALC unload so no UI binding or event delegate
// can keep the plugin assembly reachable.
active.Scope.Dispose();
try try
{ {
loaded.LoadContext!.Unload(); loaded.LoadContext!.Unload();
@ -198,12 +207,16 @@ public sealed class PluginSession : IDisposable
{ {
foreach (PluginDiscoveryResult candidate in available) foreach (PluginDiscoveryResult candidate in available)
{ {
var scope = new ScopedPluginHost(_host);
LoadedPlugin loaded = PluginLoader.Load( LoadedPlugin loaded = PluginLoader.Load(
candidate.PluginDirectory, candidate.PluginDirectory,
candidate.Manifest!, candidate.Manifest!,
_host); scope);
if (!loaded.Success) if (!loaded.Success)
{ {
scope.Dispose();
if (loaded.ReleasedLoadContext is { } released)
_releasedContexts.Add(released);
AddError( AddError(
errors, errors,
id, id,
@ -215,7 +228,7 @@ public sealed class PluginSession : IDisposable
try try
{ {
loaded.Plugin!.Enable(); loaded.Plugin!.Enable();
_loaded.Add(loaded); _loaded.Add(new ActivePlugin(loaded, scope));
SafeLog( SafeLog(
static (log, message, _) => log.Info(message), static (log, message, _) => log.Info(message),
$"plugin loaded: {loaded.Manifest.Id} " $"plugin loaded: {loaded.Manifest.Id} "
@ -229,7 +242,7 @@ public sealed class PluginSession : IDisposable
catch (Exception error) catch (Exception error)
{ {
AddError(errors, id, error); AddError(errors, id, error);
ReleaseFailedEnable(loaded); ReleaseFailedEnable(loaded, scope);
} }
} }
} }
@ -257,7 +270,9 @@ public sealed class PluginSession : IDisposable
null); null);
} }
private void ReleaseFailedEnable(LoadedPlugin loaded) private void ReleaseFailedEnable(
LoadedPlugin loaded,
ScopedPluginHost scope)
{ {
try try
{ {
@ -272,6 +287,9 @@ public sealed class PluginSession : IDisposable
error); error);
} }
scope.Dispose();
_releasedContexts.Add(new WeakReference(loaded.LoadContext!));
try try
{ {
loaded.LoadContext!.Unload(); loaded.LoadContext!.Unload();
@ -358,4 +376,8 @@ public sealed class PluginSession : IDisposable
or ArgumentException or ArgumentException
or NotSupportedException or NotSupportedException
or System.Security.SecurityException; or System.Security.SecurityException;
private sealed record ActivePlugin(
LoadedPlugin Loaded,
ScopedPluginHost Scope);
} }

View file

@ -0,0 +1,171 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Plugins;
/// <summary>
/// Per-plugin host view that owns every registration made through the public
/// event/UI surfaces. Disposal is the host's rollback boundary: it removes
/// registrations even when plugin Initialize/Enable/Disable code throws.
/// </summary>
internal sealed class ScopedPluginHost : IPluginHost, IDisposable
{
private readonly IPluginHost _inner;
private readonly ScopedEvents _events;
private readonly ScopedUiRegistry _ui;
private bool _disposed;
internal ScopedPluginHost(IPluginHost inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_events = new ScopedEvents(inner.Events);
_ui = new ScopedUiRegistry(inner.Ui);
}
public bool HasUi => _inner.HasUi;
public IPluginLogger Log => _inner.Log;
public IGameState State => _inner.State;
public IEvents Events => _events;
public ISelectionService Selection => _inner.Selection;
public IUiRegistry Ui => _ui;
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_events.Dispose();
_ui.Dispose();
}
private sealed class ScopedEvents(IEvents inner) : IEvents, IDisposable
{
private readonly object _gate = new();
private readonly List<Action<WorldEntitySnapshot>> _registrations = [];
private bool _disposed;
public event Action<WorldEntitySnapshot> EntitySpawned
{
add
{
ArgumentNullException.ThrowIfNull(value);
try
{
inner.EntitySpawned += value;
}
catch
{
// A custom event source may mutate before its add accessor
// faults. Best-effort removal keeps the scope transactional.
try { inner.EntitySpawned -= value; }
catch { }
throw;
}
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(value);
return;
}
}
// Disposal may race the host subscription call. In that case
// the disposal snapshot could not see this registration, so
// the attaching thread must roll it back before returning.
try { inner.EntitySpawned -= value; }
catch { }
throw new ObjectDisposedException(nameof(ScopedEvents));
}
remove
{
if (value is null)
return;
inner.EntitySpawned -= value;
lock (_gate)
RemoveLast(value);
}
}
public void Dispose()
{
Action<WorldEntitySnapshot>[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
{
try { inner.EntitySpawned -= registrations[index]; }
catch { }
}
}
private void RemoveLast(Action<WorldEntitySnapshot> handler)
{
for (int index = _registrations.Count - 1; index >= 0; index--)
{
if (_registrations[index] != handler)
continue;
_registrations.RemoveAt(index);
return;
}
}
}
private sealed class ScopedUiRegistry : IUiRegistry, IDisposable
{
private readonly IScopedUiRegistry _inner;
private readonly object _gate = new();
private readonly List<IDisposable> _registrations = [];
private bool _disposed;
internal ScopedUiRegistry(IUiRegistry inner)
{
_inner = inner as IScopedUiRegistry
?? throw new InvalidOperationException(
"Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back.");
}
public void AddMarkupPanel(string markupPath, object binding)
{
IDisposable registration = _inner.RegisterMarkupPanel(
markupPath,
binding);
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return;
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedUiRegistry));
}
public void Dispose()
{
IDisposable[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
{
try { registrations[index].Dispose(); }
catch { }
}
}
}
}

View file

@ -301,7 +301,7 @@ internal sealed class HeadlessSessionHost : IDisposable
// descriptor.StatusFile is unset — every call site below stays // descriptor.StatusFile is unset — every call site below stays
// unconditional. // unconditional.
var statusWriter = new SessionStatusWriter(descriptor.StatusFile); var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
pluginSession = HeadlessPluginSession.Start( pluginSession = HeadlessPluginSession.Create(
runtime, runtime,
diagnostics, diagnostics,
statusWriter, statusWriter,
@ -488,6 +488,7 @@ internal sealed class HeadlessSessionHost : IDisposable
// Campaign LA slice LA1: "started" = session host start — the // Campaign LA slice LA1: "started" = session host start — the
// earliest point this session actually attempts to connect. // earliest point this session actually attempts to connect.
_statusWriter.Started(_descriptor.Id); _statusWriter.Started(_descriptor.Id);
_pluginSession.Start();
RuntimeSessionStartResult result = RuntimeSessionStartResult result =
Commands.Session.Start(Runtime.Generation); Commands.Session.Start(Runtime.Generation);
_startOutcome = result.Status; _startOutcome = result.Status;

View file

@ -19,9 +19,23 @@ internal sealed class HeadlessPluginHost
private readonly GameRuntime _runtime; private readonly GameRuntime _runtime;
private readonly IDisposable _eventSubscription; private readonly IDisposable _eventSubscription;
private readonly object _eventGate = new(); private readonly object _eventGate = new();
private Action<WorldEntitySnapshot>? _entitySpawned; private readonly List<Subscription> _subscriptions = [];
private Subscription[] _liveSnapshot = [];
private bool _disposed; private bool _disposed;
private readonly record struct ReplayEntity(
RuntimeEntityIdentity Identity,
WorldEntitySnapshot Snapshot);
private sealed class Subscription(Action<WorldEntitySnapshot> handler)
{
internal Action<WorldEntitySnapshot> Handler { get; } = handler;
internal Queue<ReplayEntity> Pending { get; } = new();
internal HashSet<RuntimeEntityIdentity> Delivered { get; } = [];
internal bool Replaying { get; set; } = true;
internal bool Active { get; set; } = true;
}
internal HeadlessPluginHost( internal HeadlessPluginHost(
GameRuntime runtime, GameRuntime runtime,
IPluginLogger logger) IPluginLogger logger)
@ -38,6 +52,10 @@ internal sealed class HeadlessPluginHost
public ISelectionService Selection => _runtime.ActionOwner.Selection; public ISelectionService Selection => _runtime.ActionOwner.Selection;
public IUiRegistry Ui => NoOpUiRegistry.Instance; public IUiRegistry Ui => NoOpUiRegistry.Instance;
/// <summary>Test-only barrier after the borrowed replay snapshot is
/// captured and before delivery starts.</summary>
internal Action? ReplayCapturedForTest { get; set; }
/// <summary> /// <summary>
/// Immutable point-in-time values produced directly from Runtime on each /// Immutable point-in-time values produced directly from Runtime on each
/// read. The caller owns the returned snapshot list; this host retains no /// read. The caller owns the returned snapshot list; this host retains no
@ -50,7 +68,7 @@ internal sealed class HeadlessPluginHost
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
var visitor = new SnapshotVisitor(_runtime); var visitor = new SnapshotVisitor(_runtime);
_runtime.Entities.Visit(visitor); _runtime.Entities.Visit(visitor);
return visitor.Snapshots; return visitor.Items.Select(static item => item.Snapshot).ToArray();
} }
} }
@ -60,20 +78,81 @@ internal sealed class HeadlessPluginHost
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
var subscription = new Subscription(value);
lock (_eventGate) lock (_eventGate)
_entitySpawned += value; {
ObjectDisposedException.ThrowIf(_disposed, this);
_subscriptions.Add(subscription);
}
// Match the graphical WorldEvents contract: a late subscriber // Arm the pending queue before borrowing Runtime's snapshot. This
// immediately observes the canonical world that exists now. // avoids a host-lock/Runtime-lock inversion while the identity
foreach (WorldEntitySnapshot snapshot in Entities) // dedup below collapses any registration present in both views.
Invoke(value, snapshot); var visitor = new SnapshotVisitor(_runtime);
_runtime.Entities.Visit(visitor);
ReplayEntity[] replay = visitor.Items.ToArray();
ReplayCapturedForTest?.Invoke();
foreach (ReplayEntity item in replay)
{
lock (_eventGate)
{
if (!subscription.Active)
return;
if (!_runtime.Entities.TryGet(
item.Identity.ServerGuid,
out RuntimeEntitySnapshot current)
|| current.Identity != item.Identity
|| !subscription.Delivered.Add(item.Identity))
{
continue;
}
}
Invoke(subscription.Handler, item.Snapshot);
}
while (true)
{
ReplayEntity pending;
lock (_eventGate)
{
if (!subscription.Active)
return;
if (!subscription.Pending.TryDequeue(out pending))
{
subscription.Replaying = false;
subscription.Delivered.Clear();
RebuildLiveSnapshotLocked();
return;
}
if (!subscription.Delivered.Add(pending.Identity))
continue;
}
Invoke(subscription.Handler, pending.Snapshot);
}
} }
remove remove
{ {
if (value is null) if (value is null)
return; return;
lock (_eventGate) lock (_eventGate)
_entitySpawned -= value; {
for (int index = _subscriptions.Count - 1; index >= 0; index--)
{
Subscription subscription = _subscriptions[index];
if (subscription.Handler != value)
continue;
subscription.Active = false;
subscription.Pending.Clear();
subscription.Delivered.Clear();
_subscriptions.RemoveAt(index);
if (!subscription.Replaying)
RebuildLiveSnapshotLocked();
break;
}
}
} }
} }
@ -81,28 +160,45 @@ internal sealed class HeadlessPluginHost
{ {
if (_disposed) if (_disposed)
return; return;
_eventSubscription.Dispose();
_disposed = true;
lock (_eventGate) lock (_eventGate)
_entitySpawned = null; {
_disposed = true;
foreach (Subscription subscription in _subscriptions)
{
subscription.Active = false;
subscription.Pending.Clear();
subscription.Delivered.Clear();
}
_subscriptions.Clear();
_liveSnapshot = [];
}
_eventSubscription.Dispose();
} }
public void OnEntity(in RuntimeEntityDelta delta) public void OnEntity(in RuntimeEntityDelta delta)
{ {
if (_disposed || delta.Change != RuntimeEntityChange.Registered) if (delta.Change != RuntimeEntityChange.Registered)
return; return;
Action<WorldEntitySnapshot>? handlers; Subscription[] toNotify;
var pending = new ReplayEntity(
delta.Entity.Identity,
Convert(_runtime, delta.Entity));
lock (_eventGate) lock (_eventGate)
handlers = _entitySpawned; {
if (handlers is null) if (_disposed)
return;
foreach (Subscription subscription in _subscriptions)
{
if (subscription.Active && subscription.Replaying)
subscription.Pending.Enqueue(pending);
}
toNotify = _liveSnapshot;
}
if (toNotify.Length == 0)
return; return;
WorldEntitySnapshot snapshot = Convert(_runtime, delta.Entity); foreach (Subscription subscription in toNotify)
foreach (Action<WorldEntitySnapshot> handler Invoke(subscription.Handler, pending.Snapshot);
in handlers.GetInvocationList().Cast<Action<WorldEntitySnapshot>>())
{
Invoke(handler, snapshot);
}
} }
public void OnLifecycle(in RuntimeLifecycleDelta delta) { } public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
@ -141,10 +237,20 @@ internal sealed class HeadlessPluginHost
private sealed class SnapshotVisitor(GameRuntime runtime) private sealed class SnapshotVisitor(GameRuntime runtime)
: IRuntimeEntityVisitor : IRuntimeEntityVisitor
{ {
internal List<WorldEntitySnapshot> Snapshots { get; } = internal List<ReplayEntity> Items { get; } =
new(runtime.Entities.Count); new(runtime.Entities.Count);
public void Visit(in RuntimeEntitySnapshot entity) => public void Visit(in RuntimeEntitySnapshot entity) =>
Snapshots.Add(Convert(runtime, entity)); Items.Add(new ReplayEntity(
entity.Identity,
Convert(runtime, entity)));
}
private void RebuildLiveSnapshotLocked()
{
_liveSnapshot = _subscriptions
.Where(static subscription =>
subscription.Active && !subscription.Replaying)
.ToArray();
} }
} }

View file

@ -14,24 +14,31 @@ internal sealed class HeadlessPluginSession : IDisposable
{ {
private readonly HeadlessPluginHost _host; private readonly HeadlessPluginHost _host;
private readonly PluginSession _plugins; private readonly PluginSession _plugins;
private readonly string[] _roots;
private readonly IReadOnlyList<string>? _allowList;
private int _disposeStage; private int _disposeStage;
private bool _started;
private bool _disposed; private bool _disposed;
private HeadlessPluginSession( private HeadlessPluginSession(
HeadlessPluginHost host, HeadlessPluginHost host,
PluginSession plugins) PluginSession plugins,
string[] roots,
IReadOnlyList<string>? allowList)
{ {
_host = host; _host = host;
_plugins = plugins; _plugins = plugins;
_roots = roots;
_allowList = allowList;
} }
internal int LoadedCount => _plugins.LoadedCount; internal int LoadedCount => _plugins.LoadedCount;
internal IPluginHost Host => _host; internal HeadlessPluginHost Host => _host;
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() => internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
_plugins.CaptureLoadContextWeakReferences(); _plugins.CaptureLoadContextWeakReferences();
internal static HeadlessPluginSession Start( internal static HeadlessPluginSession Create(
GameRuntime runtime, GameRuntime runtime,
HeadlessDiagnosticWriter diagnostics, HeadlessDiagnosticWriter diagnostics,
SessionStatusWriter statusWriter, SessionStatusWriter statusWriter,
@ -54,17 +61,21 @@ internal sealed class HeadlessPluginSession : IDisposable
var plugins = new PluginSession( var plugins = new PluginSession(
host, host,
status => Report(statusWriter, sessionId, status)); status => Report(statusWriter, sessionId, status));
try return new HeadlessPluginSession(
{ host,
plugins.Start(roots, allowList); plugins,
return new HeadlessPluginSession(host, plugins); roots.ToArray(),
} allowList);
catch }
{
plugins.Dispose(); internal void Start()
host.Dispose(); {
throw; ObjectDisposedException.ThrowIf(_disposed, this);
} if (_started)
throw new InvalidOperationException(
"The headless plugin session has already started.");
_started = true;
_plugins.Start(_roots, _allowList);
} }
public void Dispose() public void Dispose()

View file

@ -74,7 +74,9 @@ public static class SessionConfigComposer
Character = selector, Character = selector,
Policy = policy, Policy = policy,
Credential = new SessionCredentialDescriptor(), Credential = new SessionCredentialDescriptor(),
Plugins = character.Plugins.Count > 0 ? [.. character.Plugins] : null, // LA5 distinguishes an omitted allow-list (load all, preserving
// the developer flow) from an explicit empty list (load none).
Plugins = [.. character.Plugins],
LoginCommands = character.LoginCommands.Count > 0 LoginCommands = character.LoginCommands.Count > 0
? [.. character.LoginCommands] ? [.. character.LoginCommands]
: null, : null,
@ -107,9 +109,9 @@ public static class SessionConfigComposer
/// §LA3 review finding F2): the session carries <c>mode: "probe"</c>, /// §LA3 review finding F2): the session carries <c>mode: "probe"</c>,
/// no <c>character</c> selector, and no <c>policy</c> — the host /// no <c>character</c> selector, and no <c>policy</c> — the host
/// reports the account's character roster over the status stream and /// reports the account's character roster over the status stream and
/// exits without entering the world. <c>plugins</c>/<c>loginCommands</c> /// exits without entering the world. Probes carry an explicit empty
/// don't apply to a probe and are always omitted, exactly like an /// <c>plugins</c> allow-list so a plugin installed on the machine cannot
/// empty configured set on a normal session. /// run merely because the probe has no character-level plugin settings.
/// </summary> /// </summary>
public static ComposedSessionConfig ComposeProbe( public static ComposedSessionConfig ComposeProbe(
ServerProfile server, ServerProfile server,
@ -139,7 +141,7 @@ public static class SessionConfigComposer
Character = null, Character = null,
Policy = null, Policy = null,
Credential = new SessionCredentialDescriptor(), Credential = new SessionCredentialDescriptor(),
Plugins = null, Plugins = [],
LoginCommands = null, LoginCommands = null,
LoginCommandDelayMs = null, LoginCommandDelayMs = null,
StatusFile = statusFilePath, StatusFile = statusFilePath,

View file

@ -15,12 +15,24 @@ public interface IUiRegistry
void AddMarkupPanel(string markupPath, object binding); void AddMarkupPanel(string markupPath, object binding);
} }
/// <summary>
/// Host-infrastructure extension used to give each plugin a removable UI
/// registration lifetime. Plugins continue to call
/// <see cref="IUiRegistry.AddMarkupPanel"/>; the shared plugin host wraps that
/// call and owns the returned token so failed initialization, failed enable,
/// and shutdown can roll the registration back without trusting plugin code.
/// </summary>
public interface IScopedUiRegistry : IUiRegistry
{
IDisposable RegisterMarkupPanel(string markupPath, object binding);
}
/// <summary> /// <summary>
/// BCL-only UI sink for no-window plugin hosts. It intentionally retains /// BCL-only UI sink for no-window plugin hosts. It intentionally retains
/// neither markup paths nor binding objects, so a UI registration cannot keep /// neither markup paths nor binding objects, so a UI registration cannot keep
/// a plugin assembly alive after its collectible load context is unloaded. /// a plugin assembly alive after its collectible load context is unloaded.
/// </summary> /// </summary>
public sealed class NoOpUiRegistry : IUiRegistry public sealed class NoOpUiRegistry : IScopedUiRegistry
{ {
public static NoOpUiRegistry Instance { get; } = new(); public static NoOpUiRegistry Instance { get; } = new();
@ -31,4 +43,16 @@ public sealed class NoOpUiRegistry : IUiRegistry
public void AddMarkupPanel(string markupPath, object binding) public void AddMarkupPanel(string markupPath, object binding)
{ {
} }
public IDisposable RegisterMarkupPanel(string markupPath, object binding) =>
NoOpRegistration.Instance;
private sealed class NoOpRegistration : IDisposable
{
internal static NoOpRegistration Instance { get; } = new();
public void Dispose()
{
}
}
} }

View file

@ -56,6 +56,18 @@ public sealed class SessionConfigurationSharedFixtureTests
StringComparison.Ordinal); StringComparison.Ordinal);
} }
[Fact]
public void AppReaderPreservesLauncherExplicitEmptyPluginAllowList()
{
using TemporaryFile file = TemporaryFile.Create(
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
(_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path);
Assert.NotNull(session.Plugins);
Assert.Empty(session.Plugins);
}
[Fact] [Fact]
public void AppReaderAcceptsTheProductionShapedSharedFixture() public void AppReaderAcceptsTheProductionShapedSharedFixture()
{ {

View file

@ -1,4 +1,5 @@
using AcDream.App.Plugins; using AcDream.App.Plugins;
using AcDream.App.UI;
namespace AcDream.App.Tests.Plugins; namespace AcDream.App.Tests.Plugins;
@ -18,4 +19,26 @@ public class BufferedUiRegistryTests
Assert.Empty(reg.Drain()); // consumed Assert.Empty(reg.Drain()); // consumed
} }
[Fact]
public void ScopedRegistrationTokenRemovesAnAlreadyMountedElement()
{
var registry = new BufferedUiRegistry();
IDisposable registration = registry.RegisterMarkupPanel(
"plugin.xml",
new object());
BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain());
var root = new UiRoot();
var element = new UiPanel();
root.AddChild(element);
registry.CompleteMount(pending, root, element);
Assert.Contains(element, root.Children);
Assert.Equal(1, registry.RegistrationCount);
registration.Dispose();
Assert.DoesNotContain(element, root.Children);
Assert.Equal(0, registry.RegistrationCount);
}
} }

View file

@ -1,17 +1,20 @@
using System.Text.Json; using System.Text.Json;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using AcDream.App.Configuration;
using AcDream.App.Plugins; using AcDream.App.Plugins;
using AcDream.Core.Plugins; using AcDream.Core.Plugins;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Platform; using AcDream.Platform;
using AcDream.Plugin.Abstractions; using AcDream.Plugin.Abstractions;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.Tests.Fixtures.CampaignLa;
namespace AcDream.App.Tests.Plugins; namespace AcDream.App.Tests.Plugins;
public sealed class GraphicalPluginSessionTests public sealed class GraphicalPluginSessionTests
{ {
private const string FixtureId = "acdream.test.host-fixture"; private const string FixtureId = "acdream.test.host-fixture";
private const string ThrowingId = "acdream.test.throwing-fixture";
[Fact] [Fact]
public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes() public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes()
@ -27,12 +30,13 @@ public sealed class GraphicalPluginSessionTests
var ui = new BufferedUiRegistry(); var ui = new BufferedUiRegistry();
var host = new AppPluginHost(logger, state, events, selection, ui); var host = new AppPluginHost(logger, state, events, selection, ui);
using GraphicalPluginSession plugins = GraphicalPluginSession.Start( using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
paths, paths,
[FixtureId.ToUpperInvariant(), "acdream.test.missing"], [FixtureId.ToUpperInvariant(), "acdream.test.missing"],
"gui-session", "gui-session",
host, host,
new SessionStatusWriter(statusPath)); new SessionStatusWriter(statusPath));
plugins.Start();
Assert.Equal(1, plugins.LoadedCount); Assert.Equal(1, plugins.LoadedCount);
Assert.True(host.HasUi); Assert.True(host.HasUi);
@ -42,19 +46,20 @@ public sealed class GraphicalPluginSessionTests
message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal)); message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal));
JsonElement[] statuses = ReadStatuses(statusPath); JsonElement[] statuses = ReadStatuses(statusPath);
Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses)); Assert.Equal(["started", "pluginLoaded", "pluginFailed"], EventNames(statuses));
Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString()); Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
Assert.Equal( Assert.Equal(
"acdream.test.missing", "acdream.test.missing",
statuses[1].GetProperty("plugin").GetString()); statuses[2].GetProperty("plugin").GetString());
Assert.Contains( Assert.Contains(
"not found", "not found",
statuses[1].GetProperty("error").GetString(), statuses[2].GetProperty("error").GetString(),
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
WeakReference context = Assert.Single( WeakReference context = Assert.Single(
plugins.CaptureLoadContextWeakReferences()); plugins.CaptureLoadContextWeakReferences());
plugins.Dispose(); plugins.Dispose();
Assert.Equal(0, ui.RegistrationCount);
Collect(context); Collect(context);
Assert.False(context.IsAlive); Assert.False(context.IsAlive);
} }
@ -66,6 +71,12 @@ public sealed class GraphicalPluginSessionTests
ApplicationPathSet paths = Paths(temporary.Path); ApplicationPathSet paths = Paths(temporary.Path);
InstallFixture(paths.PluginsDirectory, FixtureId); InstallFixture(paths.PluginsDirectory, FixtureId);
string statusPath = Path.Combine(temporary.Path, "status.jsonl"); string statusPath = Path.Combine(temporary.Path, "status.jsonl");
string configPath = Path.Combine(temporary.Path, "session.json");
File.WriteAllText(
configPath,
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
(_, SessionDescriptor descriptor) =
SessionConfigurationLoader.Load(configPath);
var ui = new BufferedUiRegistry(); var ui = new BufferedUiRegistry();
var host = new AppPluginHost( var host = new AppPluginHost(
new CapturingLogger(), new CapturingLogger(),
@ -74,16 +85,70 @@ public sealed class GraphicalPluginSessionTests
new SelectionState(), new SelectionState(),
ui); ui);
using GraphicalPluginSession plugins = GraphicalPluginSession.Start( using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
paths, paths,
[], descriptor.Plugins,
"gui-session", "gui-session",
host, host,
new SessionStatusWriter(statusPath)); new SessionStatusWriter(statusPath));
plugins.Start();
Assert.Equal(0, plugins.LoadedCount);
Assert.NotNull(descriptor.Plugins);
Assert.Empty(descriptor.Plugins);
Assert.Empty(ui.Drain());
Assert.Equal(["started"], EventNames(ReadStatuses(statusPath)));
}
[Fact]
public void ThrowAfterRegistrationRollsBackUiAndEventsAndCollectsContext()
{
using var temporary = new TemporaryDirectory();
ApplicationPathSet paths = Paths(temporary.Path);
string pluginDirectory = InstallFixture(
paths.PluginsDirectory,
ThrowingId,
"throwing-fixture");
File.WriteAllText(
Path.Combine(pluginDirectory, "throw-after-register"),
string.Empty);
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
var events = new WorldEvents();
var ui = new BufferedUiRegistry();
var host = new AppPluginHost(
new CapturingLogger(),
new WorldGameState(),
events,
new SelectionState(),
ui);
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
paths,
[ThrowingId],
"gui-session",
host,
new SessionStatusWriter(statusPath));
plugins.Start();
Assert.Equal(0, plugins.LoadedCount); Assert.Equal(0, plugins.LoadedCount);
Assert.Empty(ui.Drain()); Assert.Empty(ui.Drain());
Assert.False(File.Exists(statusPath)); Assert.Equal(0, ui.RegistrationCount);
events.FireEntitySpawned(new WorldEntitySnapshot(
1u,
2u,
default,
System.Numerics.Quaternion.Identity));
Assert.False(File.Exists(
Path.Combine(pluginDirectory, "unexpected-callback")));
Assert.Equal(
["started", "pluginFailed"],
EventNames(ReadStatuses(statusPath)));
WeakReference context = Assert.Single(
plugins.CaptureLoadContextWeakReferences());
plugins.Dispose();
Collect(context);
Assert.False(context.IsAlive);
} }
private static ApplicationPathSet Paths(string root) => new( private static ApplicationPathSet Paths(string root) => new(
@ -114,11 +179,14 @@ public sealed class GraphicalPluginSessionTests
private static string[] EventNames(IEnumerable<JsonElement> events) => private static string[] EventNames(IEnumerable<JsonElement> events) =>
events.Select(static item => item.GetProperty("e").GetString()!).ToArray(); events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
private static void InstallFixture(string root, string id) private static string InstallFixture(
string root,
string id,
string directoryName = "host-fixture")
{ {
string source = FixtureAssemblyPath(); string source = FixtureAssemblyPath();
Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
string pluginDirectory = Path.Combine(root, "host-fixture"); string pluginDirectory = Path.Combine(root, directoryName);
Directory.CreateDirectory(pluginDirectory); Directory.CreateDirectory(pluginDirectory);
string fileName = Path.GetFileName(source); string fileName = Path.GetFileName(source);
File.Copy(source, Path.Combine(pluginDirectory, fileName)); File.Copy(source, Path.Combine(pluginDirectory, fileName));
@ -132,6 +200,7 @@ public sealed class GraphicalPluginSessionTests
entryDll = fileName, entryDll = fileName,
apiVersion = 1, apiVersion = 1,
})); }));
return pluginDirectory;
} }
private static string FixtureAssemblyPath() private static string FixtureAssemblyPath()
@ -195,8 +264,22 @@ public sealed class GraphicalPluginSessionTests
public void Dispose() public void Dispose()
{ {
if (Directory.Exists(Path)) for (int attempt = 0; Directory.Exists(Path); attempt++)
Directory.Delete(Path, recursive: true); {
try
{
Directory.Delete(Path, recursive: true);
return;
}
catch (Exception error)
when (error is IOException or UnauthorizedAccessException
&& attempt < 9)
{
GC.Collect();
GC.WaitForPendingFinalizers();
Thread.Sleep(10);
}
}
} }
} }
} }

View file

@ -447,6 +447,11 @@ public sealed class GameWindowSlice8BoundaryTests
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast() public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
{ {
string source = GameWindowSource(); string source = GameWindowSource();
string program = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Program.cs"));
string lifetime = GameWindowLifetimeSource(); string lifetime = GameWindowLifetimeSource();
string manifest = Slice( string manifest = Slice(
lifetime, lifetime,
@ -464,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests
[ [
"new ResourceShutdownStage(\"host and session barriers\"", "new ResourceShutdownStage(\"host and session barriers\"",
"new ResourceShutdownStage(\"physical ingress cleanup\"", "new ResourceShutdownStage(\"physical ingress cleanup\"",
"new ResourceShutdownStage(\"plugin host\"",
"new ResourceShutdownStage(\"frame borrowers\"", "new ResourceShutdownStage(\"frame borrowers\"",
"new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"session dependents\"",
"new ResourceShutdownStage(\"live entities\"", "new ResourceShutdownStage(\"live entities\"",
@ -499,6 +505,8 @@ public sealed class GameWindowSlice8BoundaryTests
"Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))", "Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))",
"Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))", "Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))",
"Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))", "Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))",
"new ResourceShutdownStage(\"plugin host\"",
"Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
"new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"session dependents\"",
"Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())"); "Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())");
Assert.Contains( Assert.Contains(
@ -509,7 +517,27 @@ public sealed class GameWindowSlice8BoundaryTests
"UiHost? RetainedUiHost,", "UiHost? RetainedUiHost,",
lifetime, lifetime,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains(
"IDisposable? Plugins,",
lifetime,
StringComparison.Ordinal);
Assert.Contains("_uiHost,", source, StringComparison.Ordinal); Assert.Contains("_uiHost,", source, StringComparison.Ordinal);
Assert.Contains("_pluginSession,", source, StringComparison.Ordinal);
AssertAppearsInOrder(
program,
"GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(",
"window.StartPluginHosting(pluginSession);",
"window.Run();");
AssertAppearsInOrder(
source,
"_pluginSession = pluginSession;",
"pluginSession.Start();",
"public void Run()");
AssertAppearsInOrder(
manifest,
"Hard(\"plugins\", () => ingress.Plugins?.Dispose())",
"Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))",
"Hard(\"game runtime\", () => DisposeGameRuntime(live.Runtime))");
AssertAppearsInOrder( AssertAppearsInOrder(
nativeRelease, nativeRelease,
"TryComplete();", "TryComplete();",

View file

@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Net;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Headless.Configuration; using AcDream.Headless.Configuration;
@ -7,6 +8,9 @@ using AcDream.Headless.Diagnostics;
using AcDream.Headless.Hosting; using AcDream.Headless.Hosting;
using AcDream.Headless.Plugins; using AcDream.Headless.Plugins;
using AcDream.Plugin.Abstractions; using AcDream.Plugin.Abstractions;
using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.Tests.Fixtures.CampaignLa;
namespace AcDream.Headless.Tests; namespace AcDream.Headless.Tests;
@ -14,6 +18,7 @@ public sealed class HeadlessPluginSessionTests
{ {
private const string FixtureId = "acdream.test.host-fixture"; private const string FixtureId = "acdream.test.host-fixture";
private const string BrokenId = "acdream.test.broken"; private const string BrokenId = "acdream.test.broken";
private const string ThrowingId = "acdream.test.throwing-fixture";
[Fact] [Fact]
public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads() public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads()
@ -29,8 +34,10 @@ public sealed class HeadlessPluginSessionTests
Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath), Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath),
credential, credential,
diagnostics, diagnostics,
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]); pluginRoots: [temporary.Path]);
HeadlessPluginSession plugins = session.Plugins; HeadlessPluginSession plugins = session.Plugins;
_ = session.Start();
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
Assert.Equal(1, plugins.LoadedCount); Assert.Equal(1, plugins.LoadedCount);
@ -47,12 +54,17 @@ public sealed class HeadlessPluginSessionTests
Assert.Equal(2, plugins.Host.State.Entities.Count); Assert.Equal(2, plugins.Host.State.Entities.Count);
JsonElement[] statuses = ReadStatuses(statusPath); JsonElement[] statuses = ReadStatuses(statusPath);
Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses)); Assert.Equal(
Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString()); [
Assert.Equal(BrokenId, statuses[1].GetProperty("plugin").GetString()); "started", "pluginLoaded", "pluginFailed", "connected",
"characterList", "enteredWorld",
],
EventNames(statuses));
Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
Assert.Equal(BrokenId, statuses[2].GetProperty("plugin").GetString());
Assert.Contains( Assert.Contains(
"entry dll not found", "entry dll not found",
statuses[1].GetProperty("error").GetString()!, statuses[2].GetProperty("error").GetString()!,
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString()); Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString());
@ -79,13 +91,126 @@ public sealed class HeadlessPluginSessionTests
Descriptor([], statusPath), Descriptor([], statusPath),
credential, credential,
new HeadlessDiagnosticWriter(output), new HeadlessDiagnosticWriter(output),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]); pluginRoots: [temporary.Path]);
_ = session.Start();
Assert.Equal(0, session.Plugins.LoadedCount); Assert.Equal(0, session.Plugins.LoadedCount);
Assert.False(File.Exists(statusPath)); Assert.Equal(
["started", "connected", "characterList", "enteredWorld"],
EventNames(ReadStatuses(statusPath)));
Assert.DoesNotContain("fixture-", output.ToString()); Assert.DoesNotContain("fixture-", output.ToString());
} }
[Fact]
public void ThrowAfterRegistrationRollsBackEventsAndCollectsContext()
{
using var temporary = new TemporaryDirectory();
string pluginDirectory = InstallFixture(
temporary.Path,
ThrowingId,
"throwing-fixture");
File.WriteAllText(
Path.Combine(pluginDirectory, "throw-after-register"),
string.Empty);
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
var credential = new HeadlessCredentialSecret("fixture", "password");
using var session = new HeadlessSessionHost(
Descriptor([ThrowingId], statusPath),
credential,
new HeadlessDiagnosticWriter(new StringWriter()),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
_ = session.Start();
Assert.Equal(0, session.Plugins.LoadedCount);
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
Assert.False(File.Exists(
Path.Combine(pluginDirectory, "unexpected-callback")));
Assert.Equal(
[
"started", "pluginFailed", "connected", "characterList",
"enteredWorld",
],
EventNames(ReadStatuses(statusPath)));
WeakReference context = Assert.Single(
session.Plugins.CaptureLoadContextWeakReferences());
session.Dispose();
Collect(context);
Assert.False(context.IsAlive);
}
[Fact]
public void LauncherProbeRoundTripKeepsPluginsDisabledInTheRealHost()
{
using var temporary = new TemporaryDirectory();
InstallFixture(temporary.Path, FixtureId);
string configPath = Path.Combine(temporary.Path, "probe.json");
File.WriteAllText(
configPath,
LauncherCoreSessionConfigFixture.ComposeProbe());
HeadlessSessionDescriptor descriptor = Assert.Single(
HeadlessConfigurationLoader.Load(configPath).Sessions)! with
{
StatusFile = Path.Combine(temporary.Path, "probe-status.jsonl"),
};
var credential = new HeadlessCredentialSecret("fixture", "password");
using var session = new HeadlessSessionHost(
descriptor,
credential,
new HeadlessDiagnosticWriter(new StringWriter()),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
RuntimeSessionStartResult result = session.Start();
Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, result.Status);
Assert.NotNull(descriptor.Plugins);
Assert.Empty(descriptor.Plugins);
Assert.Equal(0, session.Plugins.LoadedCount);
Assert.Equal(
["started", "connected", "characterList"],
EventNames(ReadStatuses(descriptor.StatusFile!)));
}
[Fact]
public async Task LateSubscriberReplayQueuesConcurrentRegistrationExactlyOnceInOrder()
{
using var temporary = new TemporaryDirectory();
var credential = new HeadlessCredentialSecret("fixture", "password");
using var session = new HeadlessSessionHost(
Descriptor([], Path.Combine(temporary.Path, "status.jsonl")),
credential,
new HeadlessDiagnosticWriter(new StringWriter()),
new FixtureSessionOperations(),
pluginRoots: [temporary.Path]);
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f));
HeadlessPluginHost host = session.Plugins.Host;
using var replayCaptured = new ManualResetEventSlim();
using var releaseReplay = new ManualResetEventSlim();
host.ReplayCapturedForTest = () =>
{
replayCaptured.Set();
Assert.True(releaseReplay.Wait(TimeSpan.FromSeconds(10)));
};
var observed = new List<uint>();
Action<WorldEntitySnapshot> handler = snapshot =>
{
lock (observed)
observed.Add(snapshot.Id);
};
Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler);
Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10)));
_ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f));
releaseReplay.Set();
await subscribe.WaitAsync(TimeSpan.FromSeconds(10));
host.Events.EntitySpawned -= handler;
Assert.Equal([1_000_000u, 1_000_001u], observed);
}
private static HeadlessSessionDescriptor Descriptor( private static HeadlessSessionDescriptor Descriptor(
List<string> plugins, List<string> plugins,
string statusPath) => new() string statusPath) => new()
@ -144,15 +269,19 @@ public sealed class HeadlessPluginSessionTests
private static string[] EventNames(IEnumerable<JsonElement> events) => private static string[] EventNames(IEnumerable<JsonElement> events) =>
events.Select(static item => item.GetProperty("e").GetString()!).ToArray(); events.Select(static item => item.GetProperty("e").GetString()!).ToArray();
private static void InstallFixture(string root, string id) private static string InstallFixture(
string root,
string id,
string directoryName = "host-fixture")
{ {
string source = FixtureAssemblyPath(); string source = FixtureAssemblyPath();
Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
string pluginDirectory = Path.Combine(root, "host-fixture"); string pluginDirectory = Path.Combine(root, directoryName);
Directory.CreateDirectory(pluginDirectory); Directory.CreateDirectory(pluginDirectory);
string fileName = Path.GetFileName(source); string fileName = Path.GetFileName(source);
File.Copy(source, Path.Combine(pluginDirectory, fileName)); File.Copy(source, Path.Combine(pluginDirectory, fileName));
WriteManifest(pluginDirectory, id, fileName); WriteManifest(pluginDirectory, id, fileName);
return pluginDirectory;
} }
private static void InstallBrokenPlugin(string root, string id) private static void InstallBrokenPlugin(string root, string id)
@ -214,6 +343,39 @@ public sealed class HeadlessPluginSessionTests
} }
} }
private sealed class FixtureSessionOperations : ILiveSessionOperations
{
private static readonly CharacterList.Parsed Characters = new(
0u,
[new CharacterList.Character(0x50000001u, "Fixture", 0u)],
[],
1,
"account",
true,
true);
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint);
public void Connect(WorldSession session, string user, string password)
{
}
public CharacterList.Parsed? GetCharacters(WorldSession session) => Characters;
public void EnterWorld(WorldSession session, int activeCharacterIndex)
{
}
public void Tick(WorldSession session)
{
}
public void DisposeSession(WorldSession session) => session.Dispose();
}
private sealed class TemporaryDirectory : IDisposable private sealed class TemporaryDirectory : IDisposable
{ {
internal TemporaryDirectory() internal TemporaryDirectory()
@ -228,8 +390,22 @@ public sealed class HeadlessPluginSessionTests
public void Dispose() public void Dispose()
{ {
if (Directory.Exists(Path)) for (int attempt = 0; Directory.Exists(Path); attempt++)
Directory.Delete(Path, recursive: true); {
try
{
Directory.Delete(Path, recursive: true);
return;
}
catch (Exception error)
when (error is IOException or UnauthorizedAccessException
&& attempt < 9)
{
GC.Collect();
GC.WaitForPendingFinalizers();
Thread.Sleep(10);
}
}
} }
} }
} }

View file

@ -60,6 +60,33 @@ public sealed class SessionConfigurationSharedFixtureTests
StringComparison.Ordinal); StringComparison.Ordinal);
} }
[Fact]
public void HeadlessReaderPreservesLauncherExplicitEmptyPluginAllowList()
{
using TemporaryFile file = TemporaryFile.Create(
LauncherCoreSessionConfigFixture.ComposeEmptyPlugins());
HeadlessSessionDescriptor session = Assert.Single(
HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
Assert.NotNull(session.Plugins);
Assert.Empty(session.Plugins);
}
[Fact]
public void HeadlessReaderPreservesProbeLoadNoneAllowList()
{
using TemporaryFile file = TemporaryFile.Create(
LauncherCoreSessionConfigFixture.ComposeProbe());
HeadlessSessionDescriptor session = Assert.Single(
HeadlessConfigurationLoader.Load(file.Path).Sessions)!;
Assert.Equal(HeadlessSessionMode.Probe, session.Mode);
Assert.NotNull(session.Plugins);
Assert.Empty(session.Plugins);
}
[Fact] [Fact]
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture() public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
{ {

View file

@ -175,7 +175,7 @@ public sealed class SessionConfigComposerTests
} }
[Fact] [Fact]
public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays() public void EmptyPluginsRemainAnExplicitLoadNoneAllowListWhileLoginCommandsAreOmitted()
{ {
CharacterProfile character = Character(LaunchMode.Gui); CharacterProfile character = Character(LaunchMode.Gui);
character.Plugins = []; character.Plugins = [];
@ -190,7 +190,8 @@ public sealed class SessionConfigComposerTests
sessionId: "session-empty-lists"); sessionId: "session-empty-lists");
JsonObject session = SingleSession(composed); JsonObject session = SingleSession(composed);
Assert.False(session.ContainsKey("plugins")); Assert.True(session.ContainsKey("plugins"));
Assert.Empty(session["plugins"]!.AsArray());
Assert.False(session.ContainsKey("loginCommands")); Assert.False(session.ContainsKey("loginCommands"));
} }
@ -243,7 +244,7 @@ public sealed class SessionConfigComposerTests
} }
[Fact] [Fact]
public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands() public void ProbeModeSetsModeAndCarriesExplicitLoadNonePluginAllowList()
{ {
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe( ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
Server(), Server(),
@ -256,7 +257,7 @@ public sealed class SessionConfigComposerTests
AssertKeys( AssertKeys(
session, session,
"id", "mode", "endpoint", "account", "credential", "statusFile"); "id", "mode", "endpoint", "account", "credential", "plugins", "statusFile");
Assert.Equal("session-probe", (string?)session["id"]); Assert.Equal("session-probe", (string?)session["id"]);
Assert.Equal("probe", (string?)session["mode"]); Assert.Equal("probe", (string?)session["mode"]);
@ -266,7 +267,7 @@ public sealed class SessionConfigComposerTests
Assert.Equal("standardInput", (string?)session["credential"]!["provider"]); Assert.Equal("standardInput", (string?)session["credential"]!["provider"]);
Assert.False(session.ContainsKey("character")); Assert.False(session.ContainsKey("character"));
Assert.False(session.ContainsKey("policy")); Assert.False(session.ContainsKey("policy"));
Assert.False(session.ContainsKey("plugins")); Assert.Empty(session["plugins"]!.AsArray());
Assert.False(session.ContainsKey("loginCommands")); Assert.False(session.ContainsKey("loginCommands"));
Assert.False(session.ContainsKey("loginCommandDelayMs")); Assert.False(session.ContainsKey("loginCommandDelayMs"));
Assert.Equal( Assert.Equal(

View file

@ -11,11 +11,17 @@ namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
public sealed class HostPlugin : IAcDreamPlugin public sealed class HostPlugin : IAcDreamPlugin
{ {
private IPluginHost? _host; private IPluginHost? _host;
private string? _assemblyDirectory;
private bool _throwAfterRegistration;
private int _entitiesSeen; private int _entitiesSeen;
public void Initialize(IPluginHost host) public void Initialize(IPluginHost host)
{ {
_host = host ?? throw new ArgumentNullException(nameof(host)); _host = host ?? throw new ArgumentNullException(nameof(host));
_assemblyDirectory = Path.GetDirectoryName(
typeof(HostPlugin).Assembly.Location);
_throwAfterRegistration = File.Exists(
Path.Combine(_assemblyDirectory!, "throw-after-register"));
host.Log.Info($"fixture-initialized:hasUi={host.HasUi}"); host.Log.Info($"fixture-initialized:hasUi={host.HasUi}");
} }
@ -27,6 +33,11 @@ public sealed class HostPlugin : IAcDreamPlugin
Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"), Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
this); this);
host.Events.EntitySpawned += OnEntitySpawned; host.Events.EntitySpawned += OnEntitySpawned;
if (_throwAfterRegistration)
{
throw new InvalidOperationException(
"fixture enable failed after registering UI and events");
}
host.Log.Info( host.Log.Info(
$"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}"); $"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}");
} }
@ -36,11 +47,24 @@ public sealed class HostPlugin : IAcDreamPlugin
IPluginHost? host = _host; IPluginHost? host = _host;
if (host is null) if (host is null)
return; return;
if (_throwAfterRegistration)
{
throw new InvalidOperationException(
"fixture disable intentionally refuses cleanup");
}
host.Events.EntitySpawned -= OnEntitySpawned; host.Events.EntitySpawned -= OnEntitySpawned;
host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}"); host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}");
_host = null; _host = null;
} }
private void OnEntitySpawned(WorldEntitySnapshot snapshot) => private void OnEntitySpawned(WorldEntitySnapshot snapshot)
{
_entitiesSeen++; _entitiesSeen++;
if (_throwAfterRegistration && _assemblyDirectory is not null)
{
File.AppendAllText(
Path.Combine(_assemblyDirectory, "unexpected-callback"),
$"{snapshot.Id}{Environment.NewLine}");
}
}
} }

View file

@ -55,4 +55,66 @@ internal static class LauncherCoreSessionConfigFixture
return SessionConfigComposer.Serialize(composed.Document); return SessionConfigComposer.Serialize(composed.Document);
} }
internal static string ComposeEmptyPlugins()
{
(ServerProfile server, AccountProfile account,
LauncherInstallRecord install, ApplicationPathSet paths) = Inputs();
var character = new CharacterProfile
{
Name = "Composer Character",
Id = "0x50000001",
LaunchMode = LaunchMode.Headless,
Plugins = [],
LoginCommands = [],
};
ComposedSessionConfig composed = SessionConfigComposer.Compose(
server,
account,
character,
install,
paths,
"composer-empty-plugins");
return SessionConfigComposer.Serialize(composed.Document);
}
internal static string ComposeProbe()
{
(ServerProfile server, AccountProfile account,
LauncherInstallRecord install, ApplicationPathSet paths) = Inputs();
ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe(
server,
account,
install,
paths,
"composer-probe");
return SessionConfigComposer.Serialize(composed.Document);
}
private static (
ServerProfile Server,
AccountProfile Account,
LauncherInstallRecord Install,
ApplicationPathSet Paths) Inputs() =>
(
new ServerProfile
{
Name = "Composer Server",
Host = "composer.example",
Port = 9010,
},
new AccountProfile
{
Account = "composer-account",
Password = Password,
},
new LauncherInstallRecord(
"composer-dats",
"composer-dats/acdream.pak"),
new ApplicationPathSet(
Path.Combine(Path.GetTempPath(), "composer-config"),
Path.Combine(Path.GetTempPath(), "composer-data"),
Path.Combine(Path.GetTempPath(), "composer-cache"),
LegacyConfigDirectory: null));
} }