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;
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);

View file

@ -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);
}
}

View file

@ -14,10 +14,24 @@ namespace AcDream.App.Plugins;
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)
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;
@ -25,7 +39,7 @@ internal sealed class GraphicalPluginSession : IDisposable
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
_plugins.CaptureLoadContextWeakReferences();
internal static GraphicalPluginSession Start(
internal static GraphicalPluginSession Create(
ApplicationPathSet paths,
IReadOnlyList<string>? allowList,
string sessionId,
@ -40,21 +54,28 @@ internal sealed class GraphicalPluginSession : IDisposable
var plugins = new PluginSession(
host,
status => Report(statusWriter, sessionId, status));
try
{
plugins.Start(
return new GraphicalPluginSession(
plugins,
[
Path.Combine(AppContext.BaseDirectory, "plugins"),
paths.PluginsDirectory,
],
allowList);
return new GraphicalPluginSession(plugins);
}
catch
{
plugins.Dispose();
throw;
}
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();

View file

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

View file

@ -431,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
@ -723,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();
@ -1555,9 +1574,7 @@ public sealed class GameWindow :
sessionPlayer),
frameRoots => new SessionStartCompositionPhase(
new SessionStartDependencies(
Console.WriteLine,
_statusWriter,
_options.SessionId ?? "app"))
Console.WriteLine))
.Start(frameRoots));
}
@ -1704,6 +1721,7 @@ public sealed class GameWindow :
_kbSource,
_retailUiLease,
_uiHost,
_pluginSession,
_runtime,
_movementInput,
_cameraInput,

View file

@ -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()),

View file

@ -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}");
}
}