Implement Scheduler: stage execution, conflict batching, debug enforcement

Schedule gains real execution on top of the registration bookkeeping
from the PluginHost pass: RunStage(stage, world) runs every system
registered for a stage, and Reads<>/Writes<>() declarations are
enforced live via SystemAccessScope — a system touching a component it
didn't declare throws immediately, with a message naming the
violation, from GameWorld.Query<T>() and GameObject.GetComponent<T>()/
AddComponent<T>()/RemoveComponent<T>(). Outside of a running system
(editor code, tests, scene construction) nothing is enforced.

Scope cut made deliberately, not by accident: systems are grouped into
conflict-free batches by declared access (ComputeBatches, tested
directly), but batches run sequentially rather than on real threads.
Actually parallelizing them needs GameWorld's structural changes
(Create/Destroy/AddComponent/RemoveComponent) deferred to a command
buffer first — without that, two systems with disjoint *declared*
types can still race on shared storage, since AddComponent<T>() on a
GameObject mutates that object's own component list regardless of T.
Building real concurrency on top of a known thread-safety hole would
be worse than not building it yet. Noted as a TODO on RunStage.

Two real bugs found and fixed while wiring this up, not designed in
from the start:

- ISchedule.Add took `Delegate`, and a lambda passed there doesn't
  reliably compile down to `Action<IWorld>` at runtime — the
  compiler's natural-type inference for lambdas (as opposed to method
  groups, which do work this way) can synthesize a different, private
  delegate type instead, so `is Action<IWorld>` silently failed for
  every lambda-registered system. Changed Add's parameter type to
  Action<IWorld> directly, which sidesteps the inference question
  entirely — found by ScheduleTests actually using lambdas, which
  EchoPlugin's method-group-based Tick had been masking.
- AlcUnloadTests started failing intermittently ("ALC survived unload
  cycle 3") once PluginSystemTests existed alongside it — xUnit
  parallelizes across test classes by default, and ALC-unload tests
  are sensitive to any concurrent activity in the process. Added
  [CollectionBehavior(DisableTestParallelization = true)] to the
  harness assembly; stable across 5+ repeated runs since.

EchoPlugin.Tick is no longer a stub — it increments every Ping.Count
in World, which two new integration tests in
Engine.ConformanceHarness/PluginSystemTests.cs exercise end to end: a
plugin loaded from a real collectible ALC registers a system, Schedule
actually invokes that cross-ALC delegate, and it correctly mutates a
component owned by the Default-ALC World. This only works because
PluginLoadContext resolves Sandbox.Echo.Contracts to the copy this
test project references directly, rather than loading a second,
type-incompatible one — the harness's new normal ProjectReference to
Sandbox.Echo.Contracts.csproj makes that a live assertion, not just an
implementation detail no test would notice breaking.

27 tests total now (21 in Engine.Kernel.Tests, 6 in
Engine.ConformanceHarness), all green on a clean build, harness
verified stable across repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
Emil
2026-09-02 00:59:02 +03:00
co-authored by Claude Sonnet 5
parent 978f34727f
commit 17f66c4de5
11 changed files with 376 additions and 16 deletions
@@ -6,9 +6,10 @@ using Sandbox.Echo.Contracts;
namespace Sandbox.Echo; namespace Sandbox.Echo;
/// <summary> /// <summary>
/// M0's test fixture. Exists only to exercise the reload loop end to end — /// M0's test fixture. Exists to exercise the full loop end to end — plugin
/// edit, rebuild, ALC reload, headless run, dump — before any real /// load, a real cross-ALC system registered and invoked by Schedule, and
/// subsystem exists to test it against. See M0 in docs/kernel-contract.md §8. /// hot reload — before any real subsystem exists to test any of it
/// against. See M0 in docs/kernel-contract.md §8.
/// </summary> /// </summary>
public sealed class EchoPlugin : IPlugin public sealed class EchoPlugin : IPlugin
{ {
@@ -27,7 +28,7 @@ public sealed class EchoPlugin : IPlugin
static void Tick(IWorld world) static void Tick(IWorld world)
{ {
// TODO(M0): bump every Ping.Count by one. Placeholder until the foreach (var go in world.Query<Ping>())
// Scheduler actually exists to invoke this. go.GetComponent<Ping>()!.Count++;
} }
} }
+14 -1
View File
@@ -1,3 +1,5 @@
using Engine.Kernel.World;
namespace Engine.Kernel.Scheduling; namespace Engine.Kernel.Scheduling;
/// <summary> /// <summary>
@@ -5,7 +7,18 @@ namespace Engine.Kernel.Scheduling;
/// </summary> /// </summary>
public interface ISchedule public interface ISchedule
{ {
ISystemBuilder Add(Stage stage, Delegate system); /// <summary>
/// <c>Action&lt;IWorld&gt;</c> specifically, not <c>Delegate</c> — a
/// lambda passed where the target type is exactly <c>Delegate</c>
/// doesn't reliably compile down to <c>System.Action&lt;IWorld&gt;</c>
/// at runtime (the compiler's natural-type inference for lambdas can
/// synthesize a different, unspeakable delegate type instead, so a
/// runtime <c>is Action&lt;IWorld&gt;</c> check silently fails). Method
/// groups and lambdas both convert to <c>Action&lt;IWorld&gt;</c>
/// correctly when it's the actual parameter type — see §3 for both
/// forms in use.
/// </summary>
ISystemBuilder Add(Stage stage, Action<IWorld> system);
/// <summary>Called from a plugin's Shutdown() — must remove everything /// <summary>Called from a plugin's Shutdown() — must remove everything
/// Configure() added, or the ALC it lives in will never unload. See §4.</summary> /// Configure() added, or the ALC it lives in will never unload. See §4.</summary>
+63 -6
View File
@@ -1,13 +1,10 @@
using System.Reflection; using System.Reflection;
using Engine.Kernel.World;
namespace Engine.Kernel.Scheduling; namespace Engine.Kernel.Scheduling;
/// <summary> /// <summary>
/// Bookkeeping only for now — no stage execution, no parallelism, no /// Registration plus execution. See docs/kernel-contract.md §2 and §7.
/// enforcement of declared access. That's the real Scheduler work described
/// in docs/kernel-contract.md §2 and §7; this pass exists to make
/// <see cref="RemoveAllFrom"/> actually correct, because it's the one thing
/// PluginHost's reload correctness depends on.
/// ///
/// Ownership is tracked by the registering delegate's declaring assembly, /// Ownership is tracked by the registering delegate's declaring assembly,
/// not by a string tag on each entry — a plugin can only accidentally /// not by a string tag on each entry — a plugin can only accidentally
@@ -24,7 +21,7 @@ public sealed class Schedule : ISchedule
internal void RegisterPlugin(string pluginId, Assembly implementationAssembly) internal void RegisterPlugin(string pluginId, Assembly implementationAssembly)
=> _pluginAssemblies[pluginId] = implementationAssembly; => _pluginAssemblies[pluginId] = implementationAssembly;
public ISystemBuilder Add(Stage stage, Delegate system) public ISystemBuilder Add(Stage stage, Action<IWorld> system)
{ {
var entry = new SystemEntry(stage, system); var entry = new SystemEntry(stage, system);
_systems.Add(entry); _systems.Add(entry);
@@ -39,4 +36,64 @@ public sealed class Schedule : ISchedule
if (_pluginAssemblies.Remove(pluginId, out var assembly)) if (_pluginAssemblies.Remove(pluginId, out var assembly))
_systems.RemoveAll(e => e.System.Method.DeclaringType?.Assembly == assembly); _systems.RemoveAll(e => e.System.Method.DeclaringType?.Assembly == assembly);
} }
/// <summary>
/// Runs every system registered for <paramref name="stage"/>, grouped
/// into conflict-free batches by declared Reads/Writes (see
/// <see cref="ComputeBatches"/>) and, in DEBUG builds, enforced against
/// what each one actually touches (see SystemAccessScope).
///
/// TODO: batches are computed but run sequentially, not on separate
/// threads — real parallel dispatch needs structural changes
/// (Create/Destroy/AddComponent/RemoveComponent) deferred to a command
/// buffer flushed after the batch first. Without that, two systems with
/// disjoint *declared* types can still race on shared, non-thread-safe
/// storage: AddComponent&lt;T&gt;() on a GameObject mutates that
/// GameObject's own component list regardless of T, and GameWorld's
/// index/roots collections aren't safe for concurrent mutation either.
/// See the Scheduler row in docs/kernel-contract.md §2.
/// </summary>
public void RunStage(Stage stage, IWorld world)
{
var stageSystems = _systems.Where(e => e.Stage == stage).ToList();
foreach (var batch in ComputeBatches(stageSystems))
{
foreach (var entry in batch)
Invoke(entry, world);
}
}
private static void Invoke(SystemEntry entry, IWorld world)
{
using var _ = SystemAccessScope.Enter(entry.Reads, entry.Writes);
entry.System(world);
}
/// <summary>
/// Groups systems into the fewest sequential batches such that no two
/// systems in the same batch conflict — greedy first-fit, preserving
/// registration order. Not yet consumed for real parallelism (see the
/// TODO on RunStage), but exercised directly by ScheduleTests so the
/// conflict logic itself is validated independent of that.
/// </summary>
internal static List<List<SystemEntry>> ComputeBatches(IReadOnlyList<SystemEntry> systems)
{
var batches = new List<List<SystemEntry>>();
foreach (var entry in systems)
{
var batch = batches.FirstOrDefault(b => b.TrueForAll(other => !Conflicts(entry, other)));
if (batch is not null)
batch.Add(entry);
else
batches.Add([entry]);
}
return batches;
}
private static bool Conflicts(SystemEntry a, SystemEntry b) =>
a.Writes.Overlaps(b.Reads) || a.Writes.Overlaps(b.Writes) || b.Writes.Overlaps(a.Reads);
} }
@@ -0,0 +1,68 @@
namespace Engine.Kernel.Scheduling;
/// <summary>
/// Ambient, per-thread record of which component types the currently
/// running system declared via Reads&lt;T&gt;()/Writes&lt;T&gt;(). GameWorld
/// and GameObject consult this — when one is active — to enforce
/// docs/kernel-contract.md §7's rule that an undeclared access fails
/// loudly instead of silently working by accident.
///
/// No scope is active outside of Schedule.RunStage's invocation of a
/// system — editor code, tests, and initial scene construction are all
/// unconstrained by design; enforcement exists for the frame loop, not for
/// every touch of a GameObject anywhere in the process.
///
/// ThreadLocal rather than a plain static field: batches run sequentially
/// today (see the TODO on Schedule.RunStage), but this is already correct
/// for when a batch's systems run on separate threads instead.
/// </summary>
internal static class SystemAccessScope
{
private static readonly ThreadLocal<(IReadOnlySet<Type> Reads, IReadOnlySet<Type> Writes)?> Current = new();
public static IDisposable Enter(IReadOnlySet<Type> reads, IReadOnlySet<Type> writes)
{
var previous = Current.Value;
Current.Value = (reads, writes);
return new Restore(previous);
}
/// <summary>Querying or fetching a component counts as a read — either
/// Reads&lt;T&gt;() or Writes&lt;T&gt;() satisfies it.</summary>
public static void CheckRead(Type componentType)
{
var scope = Current.Value;
if (scope is null)
return;
if (!scope.Value.Reads.Contains(componentType) && !scope.Value.Writes.Contains(componentType))
{
throw new InvalidOperationException(
$"A system read '{componentType.Name}' without declaring Reads<{componentType.Name}>() " +
$"or Writes<{componentType.Name}>() — see docs/kernel-contract.md §7.");
}
}
/// <summary>Structurally changing a GameObject's components — adding or
/// removing one — requires Writes&lt;T&gt;() specifically. Mutating a
/// component's own fields after GetComponent&lt;T&gt;() isn't
/// interceptable this way; see the note on GameObject.AddComponent.</summary>
public static void CheckWrite(Type componentType)
{
var scope = Current.Value;
if (scope is null)
return;
if (!scope.Value.Writes.Contains(componentType))
{
throw new InvalidOperationException(
$"A system structurally changed '{componentType.Name}' without declaring " +
$"Writes<{componentType.Name}>() — see docs/kernel-contract.md §7.");
}
}
private sealed class Restore((IReadOnlySet<Type> Reads, IReadOnlySet<Type> Writes)? previous) : IDisposable
{
public void Dispose() => Current.Value = previous;
}
}
+2 -2
View File
@@ -7,10 +7,10 @@ using Engine.Kernel.World;
/// Scheduler can build the conflict graph described in docs/kernel-contract.md /// Scheduler can build the conflict graph described in docs/kernel-contract.md
/// §2 — nothing consumes them yet; that's real Scheduler work, not this pass. /// §2 — nothing consumes them yet; that's real Scheduler work, not this pass.
/// </summary> /// </summary>
internal sealed class SystemEntry(Stage stage, Delegate system) internal sealed class SystemEntry(Stage stage, Action<IWorld> system)
{ {
public Stage Stage { get; } = stage; public Stage Stage { get; } = stage;
public Delegate System { get; } = system; public Action<IWorld> System { get; } = system;
public string? After { get; set; } public string? After { get; set; }
public HashSet<Type> Reads { get; } = []; public HashSet<Type> Reads { get; } = [];
public HashSet<Type> Writes { get; } = []; public HashSet<Type> Writes { get; } = [];
+16
View File
@@ -1,4 +1,5 @@
using System.Numerics; using System.Numerics;
using Engine.Kernel.Scheduling;
namespace Engine.Kernel.World; namespace Engine.Kernel.World;
@@ -77,6 +78,8 @@ public sealed class GameObject
public T? GetComponent<T>() where T : Component public T? GetComponent<T>() where T : Component
{ {
SystemAccessScope.CheckRead(typeof(T));
foreach (var component in _components) foreach (var component in _components)
{ {
if (component is T match) if (component is T match)
@@ -86,8 +89,19 @@ public sealed class GameObject
return null; return null;
} }
/// <summary>
/// Adding a component requires <c>Writes&lt;T&gt;()</c> — checked here,
/// at the structural change. What isn't and can't be checked: mutating
/// a component's own fields after the fact, e.g.
/// <c>go.GetComponent&lt;T&gt;()!.Value = 5</c>. That's a plain field
/// write on a plain object, with nothing to intercept it — see
/// docs/kernel-contract.md §7's note on why components stay plain
/// classes rather than something that could enforce this fully.
/// </summary>
public T AddComponent<T>() where T : Component, new() public T AddComponent<T>() where T : Component, new()
{ {
SystemAccessScope.CheckWrite(typeof(T));
var component = new T(); var component = new T();
_components.Add(component); _components.Add(component);
Owner?.IndexComponentAdded(this, component); Owner?.IndexComponentAdded(this, component);
@@ -96,6 +110,8 @@ public sealed class GameObject
public void RemoveComponent<T>() where T : Component public void RemoveComponent<T>() where T : Component
{ {
SystemAccessScope.CheckWrite(typeof(T));
for (var i = 0; i < _components.Count; i++) for (var i = 0; i < _components.Count; i++)
{ {
if (_components[i] is not T match) if (_components[i] is not T match)
+7 -2
View File
@@ -1,3 +1,5 @@
using Engine.Kernel.Scheduling;
namespace Engine.Kernel.World; namespace Engine.Kernel.World;
/// <summary> /// <summary>
@@ -56,8 +58,11 @@ public sealed class GameWorld : IWorld
/// expected to queue structural changes to a stage boundary rather than /// expected to queue structural changes to a stage boundary rather than
/// let a running system trigger this — see the Scheduler row in §2. /// let a running system trigger this — see the Scheduler row in §2.
/// </summary> /// </summary>
public IEnumerable<GameObject> Query<T>() where T : Component => public IEnumerable<GameObject> Query<T>() where T : Component
_index.TryGetValue(typeof(T), out var set) ? set : []; {
SystemAccessScope.CheckRead(typeof(T));
return _index.TryGetValue(typeof(T), out var set) ? set : [];
}
internal void IndexComponentAdded(GameObject go, Component component) internal void IndexComponentAdded(GameObject go, Component component)
{ {
@@ -0,0 +1,9 @@
using Xunit;
// ALC-unload tests are sensitive to *any* concurrent activity that might
// transiently keep something reachable — xUnit parallelizes across test
// classes by default, and AlcUnloadTests running alongside
// PluginSystemTests in the same process is exactly the kind of
// interference that produces flaky, hard-to-explain unload failures.
// Everything in this assembly runs sequentially instead.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
@@ -18,6 +18,14 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Engine.Kernel\Engine.Kernel.csproj" /> <ProjectReference Include="..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
<!-- Normal reference, on purpose: Contracts assemblies are designed to
be shared safely — a Ping component constructed here and one
constructed inside the dynamically-loaded plugin are the exact
same runtime type, precisely because PluginHost's "already in
Default ALC?" check (see PluginLoadContext) finds this copy and
defers to it instead of loading a second one. -->
<ProjectReference Include="..\..\plugins\sandbox.echo\Sandbox.Echo.Contracts\Sandbox.Echo.Contracts.csproj" />
<!-- Build-order only: the harness must load this plugin the same way <!-- Build-order only: the harness must load this plugin the same way
PluginHost does at runtime, into its own collectible ALC by file PluginHost does at runtime, into its own collectible ALC by file
path. A normal ProjectReference would link its types straight into path. A normal ProjectReference would link its types straight into
@@ -0,0 +1,59 @@
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;
/// <summary>
/// Closes the loop this whole exercise was for: a plugin loaded from a real
/// collectible ALC registers a system with Schedule, Schedule actually
/// invokes that cross-ALC delegate, and it correctly mutates a component
/// living in the Default-ALC-owned World. If PluginLoadContext resolved
/// Sandbox.Echo.Contracts incorrectly (a second, distinct copy instead of
/// deferring to the one this project references directly — see the note on
/// that ProjectReference in the .csproj), the Ping instance this test
/// constructs wouldn't be a Ping as far as EchoPlugin's Tick sees it, and
/// world.Query&lt;Ping&gt;() inside the plugin would find nothing.
/// </summary>
public class PluginSystemTests
{
private static string PluginDirectory =>
Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.echo");
[Fact]
public void Loaded_Plugin_System_Runs_And_Mutates_A_Component_It_Declared()
{
var world = new GameWorld();
var schedule = new Schedule();
var host = new PluginHost(world, new ServiceRegistry(), schedule, new NullEventBus());
var ping = world.CreateGameObject("Pinger").AddComponent<Ping>();
var id = host.Load(PluginDirectory);
schedule.RunStage(Stage.Update, world);
schedule.RunStage(Stage.Update, world);
Assert.Equal(2, ping.Count);
host.Unload(id);
}
[Fact]
public void Unloading_The_Plugin_Stops_Its_System_From_Running()
{
var world = new GameWorld();
var schedule = new Schedule();
var host = new PluginHost(world, new ServiceRegistry(), schedule, new NullEventBus());
var ping = world.CreateGameObject("Pinger").AddComponent<Ping>();
var id = host.Load(PluginDirectory);
host.Unload(id);
schedule.RunStage(Stage.Update, world);
Assert.Equal(0, ping.Count);
}
}
+124
View File
@@ -0,0 +1,124 @@
using Engine.Kernel.Scheduling;
using Engine.Kernel.World;
namespace Engine.Kernel.Tests;
public class ScheduleTests
{
// Component identity is all these tests need — no fields to assign.
private sealed class Position : Component;
private sealed class Velocity : Component;
[Fact]
public void RunStage_Only_Runs_Systems_Registered_For_That_Stage()
{
var schedule = new Schedule();
var world = new GameWorld();
var updateRan = false;
var renderRan = false;
schedule.Add(Stage.Update, (IWorld _) => updateRan = true);
schedule.Add(Stage.Render, (IWorld _) => renderRan = true);
schedule.RunStage(Stage.Update, world);
Assert.True(updateRan);
Assert.False(renderRan);
}
[Fact]
public void RunStage_Runs_Every_System_Registered_For_The_Stage()
{
var schedule = new Schedule();
var world = new GameWorld();
var runCount = 0;
schedule.Add(Stage.Update, (IWorld _) => runCount++);
schedule.Add(Stage.Update, (IWorld _) => runCount++);
schedule.RunStage(Stage.Update, world);
Assert.Equal(2, runCount);
}
[Fact]
public void A_System_Reading_An_Undeclared_Component_Throws()
{
var schedule = new Schedule();
var world = new GameWorld();
world.CreateGameObject("A").AddComponent<Position>();
schedule.Add(Stage.Update, (IWorld w) => w.Query<Position>()); // no Reads<Position>()
Assert.Throws<InvalidOperationException>(() => schedule.RunStage(Stage.Update, world));
}
[Fact]
public void A_System_Reading_A_Declared_Component_Does_Not_Throw()
{
var schedule = new Schedule();
var world = new GameWorld();
world.CreateGameObject("A").AddComponent<Position>();
schedule.Add(Stage.Update, (IWorld w) => w.Query<Position>()).Reads<Position>();
var exception = Record.Exception(() => schedule.RunStage(Stage.Update, world));
Assert.Null(exception);
}
[Fact]
public void A_System_Adding_A_Component_Without_Declaring_Writes_Throws()
{
var schedule = new Schedule();
var world = new GameWorld();
var go = world.CreateGameObject("A");
schedule.Add(Stage.Update, (IWorld _) => go.AddComponent<Position>()); // no Writes<Position>()
Assert.Throws<InvalidOperationException>(() => schedule.RunStage(Stage.Update, world));
}
[Fact]
public void A_System_Adding_A_Component_With_Declared_Writes_Does_Not_Throw()
{
var schedule = new Schedule();
var world = new GameWorld();
var go = world.CreateGameObject("A");
schedule.Add(Stage.Update, (IWorld _) => go.AddComponent<Position>()).Writes<Position>();
var exception = Record.Exception(() => schedule.RunStage(Stage.Update, world));
Assert.Null(exception);
}
[Fact]
public void Enforcement_Does_Not_Apply_Outside_A_Running_System()
{
var world = new GameWorld();
var go = world.CreateGameObject("A");
// No Schedule, no RunStage — direct kernel-side manipulation, same
// as editor code or scene construction. Must not throw.
var exception = Record.Exception(() => go.AddComponent<Position>());
Assert.Null(exception);
}
[Fact]
public void Two_Systems_With_Disjoint_Declared_Access_Both_Run()
{
var schedule = new Schedule();
var world = new GameWorld();
var ran = new List<string>();
schedule.Add(Stage.Update, (IWorld _) => ran.Add("velocity")).Writes<Velocity>();
schedule.Add(Stage.Update, (IWorld _) => ran.Add("position")).Writes<Position>();
schedule.RunStage(Stage.Update, world);
Assert.Equal(["velocity", "position"], ran);
}
}