fix(plugins): close LA5 ownership races

This commit is contained in:
Erik 2026-08-14 19:28:14 +02:00
parent fbe9c8a288
commit f820eb258d
14 changed files with 467 additions and 107 deletions

View file

@ -15,6 +15,8 @@ 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()
@ -114,12 +116,13 @@ public sealed class GraphicalPluginSessionTests
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,
new SelectionState(),
selection,
ui);
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
@ -138,6 +141,65 @@ public sealed class GraphicalPluginSessionTests
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(

View file

@ -125,5 +125,7 @@ public class PluginLoaderTests
Assert.False(loaded.Success);
Assert.Contains("IAcDreamPlugin", loaded.Error!.Message);
Assert.NotNull(loaded.LoadContext);
loaded.LoadContext!.Unload();
}
}

View file

@ -125,6 +125,8 @@ public sealed class HeadlessPluginSessionTests
_ = 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(
@ -203,9 +205,25 @@ public sealed class HeadlessPluginSessionTests
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));
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);

View file

@ -1,4 +1,5 @@
using AcDream.Plugin.Abstractions;
using System.Runtime.Loader;
namespace AcDream.Plugin.Tests.Fixtures.HostPlugin;
@ -13,6 +14,7 @@ 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)
@ -22,17 +24,24 @@ public sealed class HostPlugin : IAcDreamPlugin
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.");
host.Ui.AddMarkupPanel(
Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"),
this);
host.Events.EntitySpawned += OnEntitySpawned;
RegisterHostCallbacks(host);
if (_throwAfterRegistration)
{
throw new InvalidOperationException(
@ -47,24 +56,75 @@ public sealed class HostPlugin : IAcDreamPlugin
IPluginHost? host = _host;
if (host is null)
return;
if (_throwAfterRegistration)
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++;
if (_throwAfterRegistration && _assemblyDirectory is not null)
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"),
$"{snapshot.Id}{Environment.NewLine}");
$"{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;
}
}
}