fix(plugins): close LA5 ownership races
This commit is contained in:
parent
fbe9c8a288
commit
f820eb258d
14 changed files with 467 additions and 107 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -125,5 +125,7 @@ public class PluginLoaderTests
|
|||
|
||||
Assert.False(loaded.Success);
|
||||
Assert.Contains("IAcDreamPlugin", loaded.Error!.Message);
|
||||
Assert.NotNull(loaded.LoadContext);
|
||||
loaded.LoadContext!.Unload();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue