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
100 lines
4.0 KiB
C#
100 lines
4.0 KiB
C#
using System.Reflection;
|
|
using Engine.Kernel.World;
|
|
|
|
namespace Engine.Kernel.Scheduling;
|
|
|
|
/// <summary>
|
|
/// Registration plus execution. See docs/kernel-contract.md §2 and §7.
|
|
///
|
|
/// Ownership is tracked by the registering delegate's declaring assembly,
|
|
/// not by a string tag on each entry — a plugin can only accidentally
|
|
/// mislabel a system if it hands another plugin's delegate to Add(), which
|
|
/// isn't a realistic failure mode. See <see cref="RegisterPlugin"/>.
|
|
/// </summary>
|
|
public sealed class Schedule : ISchedule
|
|
{
|
|
private readonly List<SystemEntry> _systems = [];
|
|
private readonly Dictionary<string, Assembly> _pluginAssemblies = [];
|
|
|
|
/// <summary>Called by PluginHost right after loading a plugin's
|
|
/// implementation assembly, before Configure() runs.</summary>
|
|
internal void RegisterPlugin(string pluginId, Assembly implementationAssembly)
|
|
=> _pluginAssemblies[pluginId] = implementationAssembly;
|
|
|
|
public ISystemBuilder Add(Stage stage, Action<IWorld> system)
|
|
{
|
|
var entry = new SystemEntry(stage, system);
|
|
_systems.Add(entry);
|
|
return new SystemBuilder(entry);
|
|
}
|
|
|
|
public void RemoveAllFrom(string pluginId)
|
|
{
|
|
// Removing the assembly mapping here too, not just the systems —
|
|
// leaving it behind would itself be a stray reference into the
|
|
// ALC the caller is about to unload.
|
|
if (_pluginAssemblies.Remove(pluginId, out var 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<T>() 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);
|
|
}
|