fix(plugins): close LA5 host lifecycle review
This commit is contained in:
parent
95f4be94db
commit
fbe9c8a288
25 changed files with 1043 additions and 120 deletions
|
|
@ -4,11 +4,7 @@ using AcDream.Runtime.Session;
|
|||
namespace AcDream.App.Composition;
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue