using Engine.Kernel.Diagnostics;
using Engine.Kernel.Events;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
using Engine.Kernel.Services;
using Engine.Kernel.World;
using Sandbox.Echo.Contracts;
namespace Engine.ConformanceHarness;
///
/// Found by independent review, not by any test: PluginHost.Load didn't
/// roll back anything when Configure threw partway through — Schedule.Add/
/// Events.Subscribe calls it already made stayed registered forever, and
/// the ALC it loaded into was never unloaded. Sandbox.FailingConfigure
/// exists specifically to load real, into a real collectible ALC, and
/// throw after registering a system, exactly reproducing that shape.
///
/// The system it registers increments a shared Ping component — a real,
/// deterministic side effect. An earlier version of this test tried to
/// prove the rollback via AssemblyLoadContext.All instead (did the failed
/// load's ALC get collected?), but that passed even against the
/// deliberately-reverted buggy PluginHost.Load: the ALC turned out to get
/// collected either way once its only references (local variables inside
/// Load) went out of scope, regardless of whether Schedule/EventBus still
/// held onto its assembly. Watching whether the dangling system still
/// fires is the thing that actually distinguishes rolled-back from not.
///
public class FailedConfigureRollbackTests
{
private static string PluginDirectory =>
Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.failing-configure");
private static PluginHost NewHost(Schedule schedule, GameWorld world) =>
new(world, new ServiceRegistry(), schedule, new EventBus(), new Time());
[Fact]
public void Load_Rethrows_The_Configure_Exception()
{
var host = NewHost(new Schedule(), new GameWorld());
var ex = Assert.Throws(() => host.Load(PluginDirectory));
Assert.Equal("deliberate failure for PluginHostTests", ex.Message);
}
[Fact]
public void Load_Fails_The_Same_Way_On_Retry_Instead_Of_Already_Loaded()
{
var host = NewHost(new Schedule(), new GameWorld());
Assert.Throws(() => host.Load(PluginDirectory));
// Before the fix, _loaded never got an entry either way — this
// alone wouldn't have caught the bug — but it's still the right
// thing to be true: retrying isn't "already loaded," it fails the
// same way every time.
var ex = Assert.Throws(() => host.Load(PluginDirectory));
Assert.Equal("deliberate failure for PluginHostTests", ex.Message);
}
[Fact]
public void IsLoaded_Is_False_After_A_Failed_Load_So_Retry_Does_Not_Need_Unload_First()
{
var host = NewHost(new Schedule(), new GameWorld());
Assert.Throws(() => host.Load(PluginDirectory));
Assert.False(host.IsLoaded("sandbox.failing-configure"));
// Mirrors Engine.Host's own "r " handler: only Unload first if
// IsLoaded says so. Before that check existed, this exact retry
// sequence threw "Plugin 'sandbox.failing-configure' is not
// loaded" instead of ever reaching Load again.
if (host.IsLoaded("sandbox.failing-configure"))
host.Unload("sandbox.failing-configure");
Assert.Throws(() => host.Load(PluginDirectory));
}
[Fact]
public void Load_Removes_The_System_Configure_Registered_Before_Throwing()
{
var world = new GameWorld();
var schedule = new Schedule();
var host = NewHost(schedule, world);
var ping = world.CreateGameObject("Pinger").AddComponent();
Assert.Throws(() => host.Load(PluginDirectory));
// Without rollback, the Tick system FailingConfigurePlugin
// registered before throwing is still sitting in Schedule and
// fires here, incrementing Count. With rollback (schedule.
// RemoveAllFrom in the catch block), it's gone, and this does
// nothing.
schedule.RunStage(Stage.Update, world);
schedule.RunStage(Stage.Update, world);
Assert.Equal(0, ping.Count);
}
}