Real decisions with real code behind them, not just answers written
into the doc:
- Time and Log: both stay in the kernel, both on IPluginContext.
Log was already built this way by accident; Time (DeltaTime,
ElapsedTime, FrameCount) ships now, split into ITime (plugin-facing,
read-only) and Time (host-facing, an internal Tick(deltaTime) only
Engine.Host calls) — the same split Schedule/ISchedule already
established. The fixed-step accumulator from the original kernel
scope is explicitly NOT included: nothing exists to test it against
yet (no physics), so building it now would be exactly the kind of
untested speculative machinery this project has avoided everywhere
else. It arrives with M4, alongside the Stage.FixedUpdate it would
drive — a "FixedUpdate" stage with no real fixed-timestep semantics
behind it would be actively misleading, not just incomplete.
- Event Bus: real Publish/Subscribe/RemoveAllFrom, not events-as-
World-entities (a worse fit for GameObject's persistent identity
than for a disposable-entity pure ECS). Given a real consumer
immediately rather than shipped as an unused API: PluginHost now
publishes PluginLoaded/PluginUnloaded, and sandbox.echo subscribes
to PluginLoaded for real, cleaning up in Shutdown() via
Events.RemoveAllFrom — mirroring exactly how Schedule.RemoveAllFrom
was proven. The 200-cycle leak test in AlcUnloadTests now exercises
this cleanup path too, not just Schedule's; still green, stable
across repeated runs. GameWorld does NOT auto-publish on every
GameObject/Component change — a publish on every structural change
would tax the hot path for listeners that usually don't exist.
- Frame stages: fixed, kernel-defined, not plugin-extensible — a
stage is part of the shared vocabulary the host's loop and every
plugin rely on. Set stays {Update, Render} until FixedUpdate earns
its place alongside the accumulator in M4.
- Data-oriented fast path: left open on purpose, but with a trigger
condition instead of a deadline — revisit when a concrete system
(particles, the standing example) needs tens of thousands of
GameObjects updated per frame AND profiling, not intuition, shows
GameObject/Component overhead is the actual bottleneck.
PluginHost's constructor changed shape: takes EventBus (concrete, not
IEventBus — it needs RegisterPlugin, same reason it already took
Schedule instead of ISchedule) and ITime. NullEventBus is gone;
every call site now constructs a real EventBus. Engine.Host advances
Time each frame — real wall-clock delta in --windowed (via the
window's own Native.Time), a fixed nominal 1/60s in --headless, which
has no wall clock to measure and needs to stay deterministic anyway.
48 tests total now (40 in Engine.Kernel.Tests, 8 in
Engine.ConformanceHarness), all green on a clean build. Verified by
hand too: headless run against sandbox.echo now logs "[sandbox.echo]
observed load of 'sandbox.echo'" — the EventBus subscription actually
firing, not just compiling.
docs/kernel-contract.md's open-questions footer rewritten to record
each decision and why, plus the kernel scope table (§2) and the
IPluginContext listing (§3) updated to match what's actually built.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
83 lines
2.7 KiB
C#
83 lines
2.7 KiB
C#
using System.Runtime.Loader;
|
|
using Engine.Kernel.Diagnostics;
|
|
using Engine.Kernel.Events;
|
|
using Engine.Kernel.Plugins;
|
|
using Engine.Kernel.Scheduling;
|
|
using Engine.Kernel.Services;
|
|
using Engine.Kernel.World;
|
|
|
|
namespace Engine.ConformanceHarness;
|
|
|
|
/// <summary>
|
|
/// The test docs/kernel-contract.md §4 calls the one thing that keeps this
|
|
/// architecture from slowly degrading: load and unload a plugin 200 times,
|
|
/// and after every cycle verify the ALC actually collected. Runs against
|
|
/// Sandbox.Echo — see the ReferenceOutputAssembly="false" note in this
|
|
/// project's .csproj for why that reference doesn't link its types in, and
|
|
/// the CopyToOutputDirectory items for how its built DLLs end up sitting
|
|
/// next to plugin.json under this project's own output.
|
|
/// </summary>
|
|
public class AlcUnloadTests
|
|
{
|
|
private static string PluginDirectory =>
|
|
Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.echo");
|
|
|
|
private static PluginHost NewHost() =>
|
|
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new EventBus(), new Time());
|
|
|
|
[Fact]
|
|
public void Plugin_Survives_200_Load_Unload_Cycles()
|
|
{
|
|
var host = NewHost();
|
|
|
|
for (var i = 0; i < 200; i++)
|
|
{
|
|
var id = host.Load(PluginDirectory);
|
|
var weakAlc = host.Unload(id);
|
|
|
|
GC.Collect();
|
|
GC.WaitForPendingFinalizers();
|
|
GC.Collect();
|
|
|
|
Assert.False(weakAlc.IsAlive, $"ALC survived unload cycle {i}.");
|
|
}
|
|
|
|
// The WeakReference check above only proves the collectible ALC
|
|
// let go. It says nothing about the Default ALC, which is never
|
|
// supposed to grow at all across reloads — Contracts loads once
|
|
// and every later cycle should find it already there. A count
|
|
// above 1 here would be a real, separate leak this test would
|
|
// otherwise miss entirely.
|
|
var contractsCopies = AssemblyLoadContext.Default.Assemblies
|
|
.Count(a => a.GetName().Name == "Sandbox.Echo.Contracts");
|
|
Assert.Equal(1, contractsCopies);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_Configures_The_Plugin_Without_Throwing()
|
|
{
|
|
var host = NewHost();
|
|
|
|
var id = host.Load(PluginDirectory);
|
|
|
|
Assert.Equal("sandbox.echo", id);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_Throws_When_The_Same_Plugin_Is_Already_Loaded()
|
|
{
|
|
var host = NewHost();
|
|
host.Load(PluginDirectory);
|
|
|
|
Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
|
|
}
|
|
|
|
[Fact]
|
|
public void Unload_Throws_When_The_Plugin_Was_Never_Loaded()
|
|
{
|
|
var host = NewHost();
|
|
|
|
Assert.Throws<InvalidOperationException>(() => host.Unload("sandbox.echo"));
|
|
}
|
|
}
|