Files
EmilandClaude Sonnet 5 3c60e2cd45 Close the kernel's four open questions
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
2026-09-02 04:57:38 +03:00

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"));
}
}